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, protocol_error_details,
6        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            message,
159            success_status_received,
160        } => {
161            let mut details = protocol_error_details(success_status_received);
162            details.session_created = Some(success_status_received);
163            AppError::new(
164                ErrorCode::ProtocolError,
165                redactor.redact(&format!("invalid response from Wekan: {message}")),
166                StableExitCode::Transport,
167            )
168            .with_details(details)
169        }
170        ClientError::EmbeddedProtocol {
171            http_status,
172            server_error,
173            server_reason,
174            server_message,
175            server_error_type,
176            server_is_client_safe,
177        } => {
178            let mut details = embedded_protocol_error_details(
179                http_status,
180                server_error,
181                server_reason,
182                server_message,
183                server_error_type,
184                server_is_client_safe,
185                redactor,
186            );
187            details.outcome_unknown = Some(true);
188            AppError::new(
189                ErrorCode::ProtocolError,
190                "Wekan returned an embedded login error without statusCode; the remote outcome may be unknown",
191                StableExitCode::Server,
192            )
193            .with_details(details)
194        }
195        ClientError::Server {
196            status,
197            server_error,
198            server_reason,
199            retry_after_seconds,
200        } => {
201            let two_factor_required = status == reqwest::StatusCode::UNAUTHORIZED
202                && server_error.as_deref() == Some("no-2fa-code");
203            let (code, message, exit_code, outcome_unknown) = match status.as_u16() {
204                400 => (
205                    ErrorCode::ProtocolError,
206                    "Wekan rejected the login request as malformed",
207                    StableExitCode::Transport,
208                    None,
209                ),
210                401 if two_factor_required => (
211                    ErrorCode::LoginRejected,
212                    "this account requires a two-factor code; retry with --code or --code-stdin",
213                    StableExitCode::Server,
214                    None,
215                ),
216                401 => (
217                    ErrorCode::LoginRejected,
218                    "Wekan rejected the username or email, password, or two-factor code",
219                    StableExitCode::Server,
220                    None,
221                ),
222                429 => (
223                    ErrorCode::LoginRateLimited,
224                    "too many failed login attempts; try again later",
225                    StableExitCode::Server,
226                    None,
227                ),
228                _ if status.is_server_error() => (
229                    ErrorCode::ServerError,
230                    "the Wekan server returned an unexpected error; the remote outcome may be unknown, so check the server before retrying",
231                    StableExitCode::Server,
232                    Some(true),
233                ),
234                _ => (
235                    ErrorCode::ServerError,
236                    "the Wekan server returned an unexpected error",
237                    StableExitCode::Server,
238                    None,
239                ),
240            };
241            let message = append_retry_after(message.to_owned(), status, retry_after_seconds);
242            let mut details = server_error_details(
243                status,
244                server_error,
245                server_reason,
246                retry_after_seconds,
247                redactor,
248            );
249            details.two_factor_required = two_factor_required.then_some(true);
250            details.outcome_unknown = outcome_unknown;
251            AppError::new(code, message, exit_code).with_details(details)
252        }
253        ClientError::EmbeddedServer {
254            http_status,
255            wekan_status_code,
256            server_error,
257            server_reason,
258        } => AppError::new(
259            ErrorCode::ServerError,
260            "the Wekan server returned an unexpected embedded login error",
261            StableExitCode::Server,
262        )
263        .with_details(embedded_server_error_details(
264            http_status,
265            wekan_status_code,
266            server_error,
267            server_reason,
268            redactor,
269        )),
270    }
271}
272
273fn response_body_error(
274    message: impl Into<String>,
275    status: reqwest::StatusCode,
276    retry_after_seconds: Option<u64>,
277) -> AppError {
278    let message = message.into();
279    let mut details = response_error_details(status, retry_after_seconds);
280    if status == reqwest::StatusCode::OK {
281        details.session_created = Some(true);
282        return AppError::new(ErrorCode::ProtocolError, message, StableExitCode::Transport)
283            .with_details(details);
284    }
285
286    let (code, exit_code, outcome_unknown) = match status.as_u16() {
287        400 => (ErrorCode::ProtocolError, StableExitCode::Transport, None),
288        401 => (ErrorCode::LoginRejected, StableExitCode::Server, None),
289        429 => (ErrorCode::LoginRateLimited, StableExitCode::Server, None),
290        _ if status.is_server_error() => {
291            (ErrorCode::ServerError, StableExitCode::Server, Some(true))
292        }
293        _ => (ErrorCode::ServerError, StableExitCode::Server, None),
294    };
295    let mut message = if status == reqwest::StatusCode::UNAUTHORIZED {
296        "Wekan rejected the username or email, password, or two-factor code".to_owned()
297    } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
298        "too many failed login attempts; try again later".to_owned()
299    } else {
300        message
301    };
302    if outcome_unknown.is_some() {
303        message
304            .push_str("; the remote outcome may be unknown, so check the server before retrying");
305    }
306    message = append_retry_after(message, status, retry_after_seconds);
307
308    details.outcome_unknown = outcome_unknown;
309    AppError::new(code, message, exit_code).with_details(details)
310}
311
312fn append_retry_after(
313    mut message: String,
314    status: reqwest::StatusCode,
315    retry_after_seconds: Option<u64>,
316) -> String {
317    if status == reqwest::StatusCode::TOO_MANY_REQUESTS
318        && let Some(seconds) = retry_after_seconds
319    {
320        message.push_str(&format!("; retry after {seconds} seconds"));
321    }
322    message
323}
324
325#[cfg(test)]
326mod tests;