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 #[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 #[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 #[arg(long)]
44 pub password_stdin: bool,
45
46 #[arg(long, conflicts_with_all = ["password_stdin", "code", "code_stdin"])]
48 pub password_raw_stdin: bool,
49
50 #[arg(long, conflicts_with_all = ["password_stdin", "code_stdin"])]
52 pub code: bool,
53
54 #[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;