Skip to main content

wekan_cli/commands/auth/
login.rs

1use clap::Args;
2
3use crate::commands::{
4    client_error::{
5        embedded_protocol_error_details, embedded_server_error_details,
6        protocol_diagnostic_details, response_error_details, server_error_details,
7    },
8    credential_ops::{credential_target, lock_credential_mutation, preflight_credentials},
9};
10use crate::{
11    client::{ClientError, LoginRequest},
12    command_result::CommandSuccess,
13    config::ResolvedTarget,
14    credentials::{CredentialStore, LoginSecretMode, SecretInputProvider},
15    error::{AppError, ErrorCode, ErrorDetails},
16    exit_code::StableExitCode,
17    redaction::Redactor,
18};
19
20use super::{ensure_credential_absent, non_empty_identity, persist_session};
21
22#[derive(Debug, Args)]
23pub struct LoginArgs {
24    /// Username for the account.
25    #[arg(
26        long,
27        value_parser = non_empty_identity,
28        required_unless_present = "email",
29        conflicts_with = "email"
30    )]
31    pub username: Option<String>,
32
33    /// Email address for the account.
34    #[arg(
35        long,
36        value_parser = non_empty_identity,
37        required_unless_present = "username",
38        conflicts_with = "username"
39    )]
40    pub email: Option<String>,
41
42    /// Read the password from the first line of standard input.
43    #[arg(long)]
44    pub password_stdin: bool,
45
46    /// Read the exact password through EOF, preserving line endings and empty input.
47    #[arg(long, conflicts_with_all = ["password_stdin", "code", "code_stdin"])]
48    pub password_raw_stdin: bool,
49
50    /// Prompt without echo for a two-factor authentication code.
51    #[arg(long, conflicts_with_all = ["password_stdin", "code_stdin"])]
52    pub code: bool,
53
54    /// Read the two-factor code from the line after the password on standard input.
55    #[arg(long, requires = "password_stdin", conflicts_with = "code")]
56    pub code_stdin: bool,
57}
58
59impl LoginArgs {
60    const fn secret_mode(&self) -> LoginSecretMode {
61        if self.password_raw_stdin {
62            LoginSecretMode::RawStdinPassword
63        } else if self.password_stdin {
64            if self.code_stdin {
65                LoginSecretMode::StdinPasswordAndCode
66            } else {
67                LoginSecretMode::StdinPassword
68            }
69        } else if self.code {
70            LoginSecretMode::PromptPasswordAndCode
71        } else {
72            LoginSecretMode::PromptPassword
73        }
74    }
75}
76
77pub(crate) async fn execute(
78    args: LoginArgs,
79    target: &mut ResolvedTarget<'_>,
80    credential_store: &dyn CredentialStore,
81    secret_input: &dyn SecretInputProvider,
82) -> Result<CommandSuccess, AppError> {
83    let client = target.client_factory().create().map_err(AppError::from)?;
84    let server_url = client.server().as_str().to_owned();
85    let credential_target = credential_target(
86        target.profile(),
87        target.credential_namespace(),
88        server_url.clone(),
89    );
90
91    preflight_credentials(credential_store, &credential_target)
92        .map_err(|error| error.with_session_created(false))?;
93    let credential_mutation = lock_credential_mutation(credential_store, &credential_target)
94        .map_err(|error| error.with_session_created(false))?;
95    ensure_credential_absent(credential_mutation.as_ref(), target)
96        .map_err(|error| error.with_session_created(false))?;
97
98    let secrets = secret_input.read_login_secrets(args.secret_mode())?;
99    let (password, code) = secrets.into_parts();
100    let request = LoginRequest {
101        username: args.username,
102        email: args.email,
103        password,
104        code,
105    };
106    let mut redactor = Redactor::with_secret(&request.password);
107    if let Some(code) = request.code.as_ref() {
108        redactor = redactor.and_secret(code);
109    }
110    let session = client
111        .login(&request)
112        .await
113        .map_err(|error| map_client_error(error, &redactor))?;
114
115    let success = persist_session(target, credential_mutation.as_ref(), server_url, session)
116        .map_err(|error| error.with_session_created(true))?;
117    Ok(CommandSuccess::Login(success))
118}
119
120fn map_client_error(error: ClientError, redactor: &Redactor<'_>) -> AppError {
121    match error {
122        error @ ClientError::EmptyNotFound { .. } =>
123            crate::commands::client_error::map_client_error(error, redactor, "login"),
124        ClientError::Build(_) => AppError::new(
125            ErrorCode::InternalError,
126            "the HTTP client could not be initialized",
127            StableExitCode::Internal,
128        ),
129        ClientError::Transport(_) => AppError::new(
130            ErrorCode::TransportError,
131            "the login request failed",
132            StableExitCode::Transport,
133        ),
134        ClientError::UnexpectedRedirect { status } => AppError::new(
135            ErrorCode::UnexpectedRedirect,
136            "the server redirected login; configure the canonical Wekan URL because login requests are never replayed",
137            StableExitCode::Transport,
138        )
139        .with_details(ErrorDetails {
140            http_status: Some(status.as_u16()),
141            ..ErrorDetails::default()
142        }),
143        ClientError::ResponseTooLarge {
144            limit_bytes,
145            status,
146            retry_after_seconds,
147        } => response_body_error(
148            format!("the server response exceeded the {limit_bytes}-byte safety limit"),
149            status,
150            retry_after_seconds,
151        ),
152        ClientError::ResponseBody {
153            status,
154            retry_after_seconds,
155            ..
156        } => response_body_error(
157            "the server response body could not be read",
158            status,
159            retry_after_seconds,
160        ),
161        ClientError::Protocol {
162            diagnostic,
163            message,
164            success_status_received,
165        } => {
166            let mut details = protocol_diagnostic_details(success_status_received, diagnostic, redactor);
167            details.session_created = Some(success_status_received);
168            AppError::new(
169                ErrorCode::ProtocolError,
170                redactor.redact(&format!("invalid response from Wekan: {message}")),
171                StableExitCode::Transport,
172            )
173            .with_details(details)
174        }
175        ClientError::EmbeddedProtocol {
176            http_status,
177            server_error,
178            server_reason,
179            server_message,
180            server_error_type,
181            server_is_client_safe,
182        } => {
183            let details = embedded_protocol_error_details(
184                http_status,
185                server_error,
186                server_reason,
187                server_message,
188                server_error_type,
189                server_is_client_safe,
190                redactor,
191            );
192
193            AppError::new(
194                ErrorCode::ProtocolError,
195                "Wekan returned an embedded login error without statusCode",
196                StableExitCode::Server,
197            )
198            .with_details(details)
199        }
200        ClientError::Server {
201            status,
202            server_error,
203            server_reason,
204            retry_after_seconds,
205        } => {
206            let two_factor_required = status == reqwest::StatusCode::UNAUTHORIZED
207                && server_error.as_deref() == Some("no-2fa-code");
208            let (code, message, exit_code) = match status.as_u16() {
209                400 => (
210                    ErrorCode::ProtocolError,
211                    "Wekan rejected the login request as malformed",
212                    StableExitCode::Transport,
213                ),
214                401 if two_factor_required => (
215                    ErrorCode::LoginRejected,
216                    "this account requires a two-factor code; retry with --code or --code-stdin",
217                    StableExitCode::Server,
218                ),
219                401 => (
220                    ErrorCode::LoginRejected,
221                    "Wekan rejected the username or email, password, or two-factor code",
222                    StableExitCode::Server,
223                ),
224                429 => (
225                    ErrorCode::LoginRateLimited,
226                    "too many failed login attempts; try again later",
227                    StableExitCode::Server,
228                ),
229                _ => (
230                    ErrorCode::ServerError,
231                    "the Wekan server returned an unexpected error",
232                    StableExitCode::Server,
233                ),
234            };
235            let message = append_retry_after(message.to_owned(), status, retry_after_seconds);
236            let mut details = server_error_details(
237                status,
238                server_error,
239                server_reason,
240                retry_after_seconds,
241                redactor,
242            );
243            details.two_factor_required = two_factor_required.then_some(true);
244
245            AppError::new(code, message, exit_code).with_details(details)
246        }
247        ClientError::EmbeddedServer {
248            http_status,
249            wekan_status_code,
250            server_error,
251            server_reason,
252        } => AppError::new(
253            ErrorCode::ServerError,
254            "the Wekan server returned an unexpected embedded login error",
255            StableExitCode::Server,
256        )
257        .with_details(embedded_server_error_details(
258            http_status,
259            wekan_status_code,
260            server_error,
261            server_reason,
262            redactor,
263        )),
264    }
265}
266
267fn response_body_error(
268    message: impl Into<String>,
269    status: reqwest::StatusCode,
270    retry_after_seconds: Option<u64>,
271) -> AppError {
272    let message = message.into();
273    let mut details = response_error_details(status, retry_after_seconds);
274    if status == reqwest::StatusCode::OK {
275        details.session_created = Some(true);
276        return AppError::new(ErrorCode::ProtocolError, message, StableExitCode::Transport)
277            .with_details(details);
278    }
279
280    let (code, exit_code) = match status.as_u16() {
281        400 => (ErrorCode::ProtocolError, StableExitCode::Transport),
282        401 => (ErrorCode::LoginRejected, StableExitCode::Server),
283        429 => (ErrorCode::LoginRateLimited, StableExitCode::Server),
284        _ => (ErrorCode::ServerError, StableExitCode::Server),
285    };
286    let mut message = if status == reqwest::StatusCode::UNAUTHORIZED {
287        "Wekan rejected the username or email, password, or two-factor code".to_owned()
288    } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
289        "too many failed login attempts; try again later".to_owned()
290    } else {
291        message
292    };
293    message = append_retry_after(message, status, retry_after_seconds);
294
295    AppError::new(code, message, exit_code).with_details(details)
296}
297
298fn append_retry_after(
299    mut message: String,
300    status: reqwest::StatusCode,
301    retry_after_seconds: Option<u64>,
302) -> String {
303    if status == reqwest::StatusCode::TOO_MANY_REQUESTS
304        && let Some(seconds) = retry_after_seconds
305    {
306        message.push_str(&format!("; retry after {seconds} seconds"));
307    }
308    message
309}
310
311#[cfg(test)]
312mod tests;