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