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 #[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_stdin"])]
48 pub code: bool,
49
50 #[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;