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