1use alloc::sync::Arc;
16use core::time::Duration;
17use std::collections::HashMap;
18use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
19
20use super::api::{LoginDriver, Step};
21use crate::{Log, Secret};
22
23#[derive(Debug, Clone)]
25pub struct Timing {
26 pub abandon_ms: i64,
28 pub keep_ms: i64,
30 pub approval_wait: Duration,
32 pub approval_ms: i64,
34 pub send_cooldown_s: i64,
36 pub sends_per_hour: usize,
38}
39
40impl Default for Timing {
41 fn default() -> Self {
42 Self {
43 abandon_ms: 120_000,
44 keep_ms: 600_000,
45 approval_wait: Duration::from_secs(5),
46 approval_ms: 300_000,
47 send_cooldown_s: 60,
48 sends_per_hour: 3,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum View {
56 Starting,
58 Code {
60 channel: String,
62 },
63 Approve,
65 Done {
67 display_name: String,
70 },
71 Failed {
73 message: String,
75 },
76 Cancelled,
78 Expired,
80}
81
82impl View {
83 pub fn finished(&self) -> bool {
85 matches!(
86 self,
87 Self::Done { .. } | Self::Failed { .. } | Self::Cancelled | Self::Expired
88 )
89 }
90
91 pub fn state(&self) -> &'static str {
94 match self {
95 Self::Starting => "starting",
96 Self::Code { .. } => "code",
97 Self::Approve => "approve",
98 Self::Done { .. } => "done",
99 Self::Failed { .. } => "failed",
100 Self::Cancelled => "cancelled",
101 Self::Expired => "expired",
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Snapshot {
109 pub id: String,
111 pub org: String,
113 pub view: View,
115 pub revision: u32,
117 pub busy: bool,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
123pub enum LoginError {
124 #[error("{0}")]
126 NotFound(&'static str),
127 #[error("{0}")]
129 Invalid(String),
130 #[error("{0}")]
132 State(&'static str),
133 #[error("{0}")]
135 Budget(String),
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum Stop {
140 Cancel,
141 Abandon,
142}
143
144#[derive(Debug)]
145struct State {
146 view: View,
147 revision: u32,
148 last_poll: i64,
149 finished_at: Option<i64>,
150 stop: Option<Stop>,
151 code: Option<Secret>,
152 busy: bool,
153}
154
155#[derive(Debug)]
156struct Pending {
157 id: String,
158 person: String,
159 org: String,
160 st: Mutex<State>,
161 cv: Condvar,
162}
163
164impl Pending {
165 fn lock(&self) -> MutexGuard<'_, State> {
166 self.st.lock().unwrap_or_else(PoisonError::into_inner)
167 }
168
169 fn snapshot(&self) -> Snapshot {
170 let st = self.lock();
171 Snapshot {
172 id: self.id.clone(),
173 org: self.org.clone(),
174 view: st.view.clone(),
175 revision: st.revision,
176 busy: st.busy,
177 }
178 }
179}
180
181pub struct LoggedIn {
184 pub org: String,
186 pub person: String,
188 pub session: serde_json::Value,
190 pub display_name: String,
193 pub sso_expires_at: Option<i64>,
195}
196
197impl core::fmt::Debug for LoggedIn {
198 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
199 f.debug_struct("LoggedIn")
200 .field("org", &self.org)
201 .field("person", &self.person)
202 .field("session", &crate::Redacted)
203 .field("display_name", &self.display_name)
204 .field("sso_expires_at", &self.sso_expires_at)
205 .finish()
206 }
207}
208
209fn scrub_phone(phone: &Secret, text: &str) -> String {
213 let given = phone.expose().trim();
214 let digits: String = given.chars().filter(char::is_ascii_digit).collect();
215 let mut forms = vec![given.to_owned(), digits.clone()];
216 for cc in 1..=3 {
217 if digits.len() >= cc + 7 {
218 forms.push(digits[cc..].to_owned());
219 }
220 }
221 forms.sort_by_key(|f| core::cmp::Reverse(f.len()));
223 forms
224 .iter()
225 .filter(|f| !f.is_empty())
226 .fold(text.to_owned(), |t, f| Secret::new(f.as_str()).redact(&t))
227}
228
229pub type OnDone = Arc<dyn Fn(LoggedIn) -> Result<(), String> + Send + Sync>;
232
233pub struct Logins {
235 pending: Mutex<HashMap<String, Arc<Pending>>>,
236 sends: Mutex<HashMap<String, Vec<i64>>>,
238 pub timing: Timing,
240 clock: Arc<dyn Fn() -> i64 + Send + Sync>,
241 log: Option<Log>,
242}
243
244impl Default for Logins {
245 fn default() -> Self {
246 Self::new(Timing::default(), Arc::new(crate::util::now_ms))
247 }
248}
249
250impl Logins {
251 pub fn new(timing: Timing, clock: Arc<dyn Fn() -> i64 + Send + Sync>) -> Self {
253 Self {
254 pending: Mutex::default(),
255 sends: Mutex::default(),
256 timing,
257 clock,
258 log: None,
259 }
260 }
261
262 #[must_use]
265 pub fn with_log(mut self, log: Log) -> Self {
266 self.log = Some(log);
267 self
268 }
269
270 fn log(&self, line: &str) {
271 if let Some(l) = &self.log {
272 l(line);
273 }
274 }
275
276 fn now(&self) -> i64 {
277 (self.clock)()
278 }
279
280 fn table(&self) -> MutexGuard<'_, HashMap<String, Arc<Pending>>> {
281 self.pending.lock().unwrap_or_else(PoisonError::into_inner)
282 }
283
284 fn mine(&self, person: &str, org: &str, id: &str) -> Result<Arc<Pending>, LoginError> {
285 self.table()
286 .get(id)
287 .filter(|p| p.person == person && p.org == org)
288 .cloned()
289 .ok_or(LoginError::NotFound("no such login"))
290 }
291
292 pub fn of(&self, person: &str, org: &str) -> Vec<Snapshot> {
294 self.reap();
295 let mut v: Vec<Snapshot> = self
296 .table()
297 .values()
298 .filter(|p| p.person == person && p.org == org)
299 .map(|p| p.snapshot())
300 .collect();
301 v.sort_by(|a, b| a.id.cmp(&b.id));
302 v
303 }
304
305 fn take_send(&self, org: &str) -> Result<(), LoginError> {
307 let now = self.now() / 1000;
308 let mut all = self.sends.lock().unwrap_or_else(PoisonError::into_inner);
309 let sends = all.entry(org.to_owned()).or_default();
310 sends.retain(|t| now - t < 3600);
311 if let Some(last) = sends.last()
312 && now - last < self.timing.send_cooldown_s
313 {
314 return Err(LoginError::Budget(format!(
315 "Grab just sent a code: wait {} s before asking again",
316 self.timing.send_cooldown_s - (now - last)
317 )));
318 }
319 if sends.len() >= self.timing.sends_per_hour {
320 return Err(LoginError::Budget(
321 "too many code sends in the last hour: try again later".into(),
322 ));
323 }
324 sends.push(now);
325 Ok(())
326 }
327
328 pub fn start(
334 self: &Arc<Self>,
335 person: &str,
336 org: &str,
337 phone: Secret,
338 driver: Box<dyn LoginDriver>,
339 done: OnDone,
340 ) -> Result<Snapshot, LoginError> {
341 self.reap();
342 let digits = phone.expose().chars().filter(char::is_ascii_digit).count();
343 if !phone.expose().trim().starts_with('+') || !(8..=15).contains(&digits) {
344 return Err(LoginError::Invalid(
345 "the phone number: in international form, + and the country code first".into(),
346 ));
347 }
348 let mut table = self.table();
351 if table
352 .values()
353 .any(|p| p.org == org && !p.lock().view.finished())
354 {
355 return Err(LoginError::State(
356 "a Grab login is already in progress for this org",
357 ));
358 }
359 self.take_send(org)?;
360 let now = self.now();
361 let p = Arc::new(Pending {
362 id: crate::util::new_id(),
363 person: person.to_owned(),
364 org: org.to_owned(),
365 st: Mutex::new(State {
366 view: View::Starting,
367 revision: 0,
368 last_poll: now,
369 finished_at: None,
370 stop: None,
371 code: None,
372 busy: false,
373 }),
374 cv: Condvar::new(),
375 });
376 table.insert(p.id.clone(), Arc::clone(&p));
377 drop(table);
378 let (hub, p2) = (Arc::clone(self), Arc::clone(&p));
379 std::thread::Builder::new()
380 .name("grab-login".into())
381 .spawn(move || {
382 let mut driver = driver;
383 let r = std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| {
384 hub.drive(&p2, driver.as_mut(), phone, &done);
385 }));
386 driver.cancel();
387 if r.is_err() {
388 hub.finish(
389 &p2,
390 View::Failed {
391 message: "the login broke (logged)".into(),
392 },
393 );
394 hub.log(&format!("org {}: the login driver panicked", p2.org));
395 }
396 })
397 .map_err(|e| {
398 self.finish(
399 &p,
400 View::Failed {
401 message: "the login couldn't start".into(),
402 },
403 );
404 LoginError::Invalid(format!("a thread: {e}"))
405 })?;
406 Ok(p.snapshot())
407 }
408
409 pub fn get(&self, person: &str, org: &str, id: &str) -> Result<Snapshot, LoginError> {
414 self.reap();
415 let p = self.mine(person, org, id)?;
416 p.lock().last_poll = self.now();
417 Ok(p.snapshot())
418 }
419
420 pub fn submit(
425 &self,
426 person: &str,
427 org: &str,
428 id: &str,
429 code: Secret,
430 ) -> Result<Snapshot, LoginError> {
431 let p = self.mine(person, org, id)?;
432 let c = code.expose().trim();
433 if c.is_empty() || c.len() > 12 || !c.chars().all(|x| x.is_ascii_alphanumeric()) {
434 return Err(LoginError::Invalid("the code: the digits Grab sent".into()));
435 }
436 {
437 let mut st = p.lock();
438 if !matches!(st.view, View::Code { .. }) {
439 return Err(LoginError::State("this login isn't asking for a code"));
440 }
441 if st.busy || st.code.is_some() {
442 return Err(LoginError::State("already submitted"));
443 }
444 st.code = Some(Secret::new(c));
445 st.busy = true;
446 st.last_poll = self.now();
447 }
448 p.cv.notify_all();
449 Ok(p.snapshot())
450 }
451
452 pub fn cancel(&self, person: &str, org: &str, id: &str) -> Result<Snapshot, LoginError> {
457 let p = self.mine(person, org, id)?;
458 stop(&p, Stop::Cancel);
459 Ok(p.snapshot())
460 }
461
462 pub fn reap(&self) {
464 let now = self.now();
465 let all: Vec<Arc<Pending>> = self.table().values().cloned().collect();
466 let mut gone = Vec::new();
467 for p in all {
468 let (abandoned, old) = {
469 let st = p.lock();
470 (
471 st.stop.is_none()
472 && !st.view.finished()
473 && now - st.last_poll > self.timing.abandon_ms,
474 st.finished_at
475 .is_some_and(|t| now - t > self.timing.keep_ms),
476 )
477 };
478 if abandoned {
479 self.log(&format!("org {}: a login was abandoned, cancelling", p.org));
480 stop(&p, Stop::Abandon);
481 }
482 if old {
483 gone.push(p.id.clone());
484 }
485 }
486 if !gone.is_empty() {
487 let mut t = self.table();
488 for id in gone {
489 t.remove(&id);
490 }
491 }
492 }
493
494 fn drive(&self, p: &Pending, d: &mut dyn LoginDriver, phone: Secret, done: &OnDone) {
495 let mut step = d.start(&phone);
497 let approve_until = self.now() + self.timing.approval_ms;
498 loop {
499 if self.stopped(p) {
500 return;
501 }
502 step = match step {
503 Step::Done {
504 session,
505 display_name,
506 sso_expires_at,
507 } => {
508 let r = done(LoggedIn {
509 org: p.org.clone(),
510 person: p.person.clone(),
511 session,
512 display_name: display_name.clone(),
513 sso_expires_at,
514 });
515 return match r {
516 Ok(()) => {
517 self.log(&format!("org {}: connected", p.org));
518 self.finish(p, View::Done { display_name });
519 }
520 Err(e) => {
521 self.log(&format!("org {}: keeping the session: {e}", p.org));
522 self.finish(
523 p,
524 View::Failed {
525 message: "couldn't keep the session (logged)".into(),
526 },
527 );
528 }
529 };
530 }
531 Step::Failed(message) => {
532 let message = scrub_phone(&phone, &message);
533 self.log(&format!("org {}: the login failed: {message}", p.org));
534 return self.finish(p, View::Failed { message });
535 }
536 Step::AwaitingApproval => {
537 if self.now() > approve_until {
538 return self.finish(
539 p,
540 View::Failed {
541 message: "nobody approved it in the Grab app in time".into(),
542 },
543 );
544 }
545 Self::publish(p, View::Approve);
546 self.abandon_if_idle(p);
547 if self.stopped(p) {
548 return;
549 }
550 d.wait(self.timing.approval_wait)
551 }
552 Step::AwaitingCode { channel } => {
553 Self::publish(p, View::Code { channel });
554 let code = loop {
555 let st = p.lock();
556 let (mut st, _) =
557 p.cv.wait_timeout_while(st, Duration::from_secs(1), |s| {
558 s.code.is_none() && s.stop.is_none()
559 })
560 .unwrap_or_else(PoisonError::into_inner);
561 if st.code.is_some() || st.stop.is_some() {
562 break st.code.take();
563 }
564 drop(st);
565 self.abandon_if_idle(p);
566 };
567 if self.stopped(p) {
568 return;
569 }
570 let Some(code) = code else { return };
571 d.submit_code(&code)
572 }
573 };
574 }
575 }
576
577 fn abandon_if_idle(&self, p: &Pending) {
580 let idle = {
581 let st = p.lock();
582 st.stop.is_none() && self.now() - st.last_poll > self.timing.abandon_ms
583 };
584 if idle {
585 self.log(&format!("org {}: a login was abandoned, cancelling", p.org));
586 stop(p, Stop::Abandon);
587 }
588 }
589
590 fn publish(p: &Pending, view: View) {
591 let mut st = p.lock();
592 if st.view.finished() {
593 return;
594 }
595 if st.view != view {
596 st.view = view;
597 st.revision += 1;
598 }
599 st.busy = false;
600 }
601
602 fn finish(&self, p: &Pending, view: View) {
603 let mut st = p.lock();
604 if st.view.finished() {
605 return;
606 }
607 st.view = view;
608 st.revision += 1;
609 st.busy = false;
610 st.code = None;
611 st.finished_at = Some(self.now());
612 drop(st);
613 p.cv.notify_all();
614 }
615
616 fn stopped(&self, p: &Pending) -> bool {
617 let Some(why) = p.lock().stop else {
618 return false;
619 };
620 self.finish(
621 p,
622 match why {
623 Stop::Cancel => View::Cancelled,
624 Stop::Abandon => View::Expired,
625 },
626 );
627 true
628 }
629}
630
631fn stop(p: &Pending, why: Stop) {
632 {
633 let mut st = p.lock();
634 if st.view.finished() || st.stop.is_some() {
635 return;
636 }
637 st.stop = Some(why);
638 }
639 p.cv.notify_all();
640}