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