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_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 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;