use alloc::sync::Arc;
use core::time::Duration;
use std::collections::HashMap;
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
use super::api::{LoginDriver, Step};
use crate::{Log, Secret};
#[derive(Debug, Clone)]
pub struct Timing {
pub abandon_ms: i64,
pub keep_ms: i64,
pub approval_wait: Duration,
pub approval_ms: i64,
pub send_cooldown_s: i64,
pub sends_per_hour: usize,
}
impl Default for Timing {
fn default() -> Self {
Self {
abandon_ms: 120_000,
keep_ms: 600_000,
approval_wait: Duration::from_secs(5),
approval_ms: 300_000,
send_cooldown_s: 60,
sends_per_hour: 3,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum View {
Starting,
Code {
channel: String,
},
Approve,
Done {
display_name: String,
},
Failed {
message: String,
},
Cancelled,
Expired,
}
impl View {
pub fn finished(&self) -> bool {
matches!(
self,
Self::Done { .. } | Self::Failed { .. } | Self::Cancelled | Self::Expired
)
}
pub fn state(&self) -> &'static str {
match self {
Self::Starting => "starting",
Self::Code { .. } => "code",
Self::Approve => "approve",
Self::Done { .. } => "done",
Self::Failed { .. } => "failed",
Self::Cancelled => "cancelled",
Self::Expired => "expired",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
pub id: String,
pub org: String,
pub view: View,
pub revision: u32,
pub busy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LoginError {
#[error("{0}")]
NotFound(&'static str),
#[error("{0}")]
Invalid(String),
#[error("{0}")]
State(&'static str),
#[error("{0}")]
Budget(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Stop {
Cancel,
Abandon,
}
#[derive(Debug)]
struct State {
view: View,
revision: u32,
last_poll: i64,
finished_at: Option<i64>,
stop: Option<Stop>,
code: Option<Secret>,
busy: bool,
}
#[derive(Debug)]
struct Pending {
id: String,
person: String,
org: String,
st: Mutex<State>,
cv: Condvar,
}
impl Pending {
fn lock(&self) -> MutexGuard<'_, State> {
self.st.lock().unwrap_or_else(PoisonError::into_inner)
}
fn snapshot(&self) -> Snapshot {
let st = self.lock();
Snapshot {
id: self.id.clone(),
org: self.org.clone(),
view: st.view.clone(),
revision: st.revision,
busy: st.busy,
}
}
}
pub struct LoggedIn {
pub org: String,
pub person: String,
pub session: serde_json::Value,
pub display_name: String,
pub sso_expires_at: Option<i64>,
}
impl core::fmt::Debug for LoggedIn {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("LoggedIn")
.field("org", &self.org)
.field("person", &self.person)
.field("session", &crate::Redacted)
.field("display_name", &self.display_name)
.field("sso_expires_at", &self.sso_expires_at)
.finish()
}
}
fn scrub_phone(phone: &Secret, text: &str) -> String {
let given = phone.expose().trim();
let digits: String = given.chars().filter(char::is_ascii_digit).collect();
let mut forms = vec![given.to_owned(), digits.clone()];
for cc in 1..=3 {
if digits.len() >= cc + 7 {
forms.push(digits[cc..].to_owned());
}
}
forms.sort_by_key(|f| core::cmp::Reverse(f.len()));
forms
.iter()
.filter(|f| !f.is_empty())
.fold(text.to_owned(), |t, f| Secret::new(f.as_str()).redact(&t))
}
pub type OnDone = Arc<dyn Fn(LoggedIn) -> Result<(), String> + Send + Sync>;
pub struct Logins {
pending: Mutex<HashMap<String, Arc<Pending>>>,
sends: Mutex<HashMap<String, Vec<i64>>>,
pub timing: Timing,
clock: Arc<dyn Fn() -> i64 + Send + Sync>,
log: Option<Log>,
}
impl Default for Logins {
fn default() -> Self {
Self::new(Timing::default(), Arc::new(crate::util::now_ms))
}
}
impl Logins {
pub fn new(timing: Timing, clock: Arc<dyn Fn() -> i64 + Send + Sync>) -> Self {
Self {
pending: Mutex::default(),
sends: Mutex::default(),
timing,
clock,
log: None,
}
}
#[must_use]
pub fn with_log(mut self, log: Log) -> Self {
self.log = Some(log);
self
}
fn log(&self, line: &str) {
if let Some(l) = &self.log {
l(line);
}
}
fn now(&self) -> i64 {
(self.clock)()
}
fn table(&self) -> MutexGuard<'_, HashMap<String, Arc<Pending>>> {
self.pending.lock().unwrap_or_else(PoisonError::into_inner)
}
fn mine(&self, person: &str, org: &str, id: &str) -> Result<Arc<Pending>, LoginError> {
self.table()
.get(id)
.filter(|p| p.person == person && p.org == org)
.cloned()
.ok_or(LoginError::NotFound("no such login"))
}
pub fn of(&self, person: &str, org: &str) -> Vec<Snapshot> {
self.reap();
let mut v: Vec<Snapshot> = self
.table()
.values()
.filter(|p| p.person == person && p.org == org)
.map(|p| p.snapshot())
.collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
fn take_send(&self, org: &str) -> Result<(), LoginError> {
let now = self.now() / 1000;
let mut all = self.sends.lock().unwrap_or_else(PoisonError::into_inner);
let sends = all.entry(org.to_owned()).or_default();
sends.retain(|t| now - t < 3600);
if let Some(last) = sends.last()
&& now - last < self.timing.send_cooldown_s
{
return Err(LoginError::Budget(format!(
"Grab just sent a code: wait {} s before asking again",
self.timing.send_cooldown_s - (now - last)
)));
}
if sends.len() >= self.timing.sends_per_hour {
return Err(LoginError::Budget(
"too many code sends in the last hour: try again later".into(),
));
}
sends.push(now);
Ok(())
}
pub fn start(
self: &Arc<Self>,
person: &str,
org: &str,
phone: Secret,
driver: Box<dyn LoginDriver>,
done: OnDone,
) -> Result<Snapshot, LoginError> {
self.reap();
let digits = phone.expose().chars().filter(char::is_ascii_digit).count();
if !phone.expose().trim().starts_with('+') || !(8..=15).contains(&digits) {
return Err(LoginError::Invalid(
"the phone number: in international form, + and the country code first".into(),
));
}
let mut table = self.table();
if table
.values()
.any(|p| p.org == org && !p.lock().view.finished())
{
return Err(LoginError::State(
"a Grab login is already in progress for this org",
));
}
self.take_send(org)?;
let now = self.now();
let p = Arc::new(Pending {
id: crate::util::new_id(),
person: person.to_owned(),
org: org.to_owned(),
st: Mutex::new(State {
view: View::Starting,
revision: 0,
last_poll: now,
finished_at: None,
stop: None,
code: None,
busy: false,
}),
cv: Condvar::new(),
});
table.insert(p.id.clone(), Arc::clone(&p));
drop(table);
let (hub, p2) = (Arc::clone(self), Arc::clone(&p));
std::thread::Builder::new()
.name("grab-login".into())
.spawn(move || {
let mut driver = driver;
let r = std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| {
hub.drive(&p2, driver.as_mut(), phone, &done);
}));
driver.cancel();
if r.is_err() {
hub.finish(
&p2,
View::Failed {
message: "the login broke (logged)".into(),
},
);
hub.log(&format!("org {}: the login driver panicked", p2.org));
}
})
.map_err(|e| {
self.finish(
&p,
View::Failed {
message: "the login couldn't start".into(),
},
);
LoginError::Invalid(format!("a thread: {e}"))
})?;
Ok(p.snapshot())
}
pub fn get(&self, person: &str, org: &str, id: &str) -> Result<Snapshot, LoginError> {
self.reap();
let p = self.mine(person, org, id)?;
p.lock().last_poll = self.now();
Ok(p.snapshot())
}
pub fn submit(
&self,
person: &str,
org: &str,
id: &str,
code: Secret,
) -> Result<Snapshot, LoginError> {
let p = self.mine(person, org, id)?;
let c = code.expose().trim();
if c.is_empty() || c.len() > 12 || !c.chars().all(|x| x.is_ascii_alphanumeric()) {
return Err(LoginError::Invalid("the code: the digits Grab sent".into()));
}
{
let mut st = p.lock();
if !matches!(st.view, View::Code { .. }) {
return Err(LoginError::State("this login isn't asking for a code"));
}
if st.busy || st.code.is_some() {
return Err(LoginError::State("already submitted"));
}
st.code = Some(Secret::new(c));
st.busy = true;
st.last_poll = self.now();
}
p.cv.notify_all();
Ok(p.snapshot())
}
pub fn cancel(&self, person: &str, org: &str, id: &str) -> Result<Snapshot, LoginError> {
let p = self.mine(person, org, id)?;
stop(&p, Stop::Cancel);
Ok(p.snapshot())
}
pub fn reap(&self) {
let now = self.now();
let all: Vec<Arc<Pending>> = self.table().values().cloned().collect();
let mut gone = Vec::new();
for p in all {
let (abandoned, old) = {
let st = p.lock();
(
st.stop.is_none()
&& !st.view.finished()
&& now - st.last_poll > self.timing.abandon_ms,
st.finished_at
.is_some_and(|t| now - t > self.timing.keep_ms),
)
};
if abandoned {
self.log(&format!("org {}: a login was abandoned, cancelling", p.org));
stop(&p, Stop::Abandon);
}
if old {
gone.push(p.id.clone());
}
}
if !gone.is_empty() {
let mut t = self.table();
for id in gone {
t.remove(&id);
}
}
}
fn drive(&self, p: &Pending, d: &mut dyn LoginDriver, phone: Secret, done: &OnDone) {
let mut step = d.start(&phone);
let approve_until = self.now() + self.timing.approval_ms;
loop {
if self.stopped(p) {
return;
}
step = match step {
Step::Done {
session,
display_name,
sso_expires_at,
} => {
let r = done(LoggedIn {
org: p.org.clone(),
person: p.person.clone(),
session,
display_name: display_name.clone(),
sso_expires_at,
});
return match r {
Ok(()) => {
self.log(&format!("org {}: connected", p.org));
self.finish(p, View::Done { display_name });
}
Err(e) => {
self.log(&format!("org {}: keeping the session: {e}", p.org));
self.finish(
p,
View::Failed {
message: "couldn't keep the session (logged)".into(),
},
);
}
};
}
Step::Failed(message) => {
let message = scrub_phone(&phone, &message);
self.log(&format!("org {}: the login failed: {message}", p.org));
return self.finish(p, View::Failed { message });
}
Step::AwaitingApproval => {
if self.now() > approve_until {
return self.finish(
p,
View::Failed {
message: "nobody approved it in the Grab app in time".into(),
},
);
}
Self::publish(p, View::Approve);
self.abandon_if_idle(p);
if self.stopped(p) {
return;
}
d.wait(self.timing.approval_wait)
}
Step::AwaitingCode { channel } => {
Self::publish(p, View::Code { channel });
let code = loop {
let st = p.lock();
let (mut st, _) =
p.cv.wait_timeout_while(st, Duration::from_secs(1), |s| {
s.code.is_none() && s.stop.is_none()
})
.unwrap_or_else(PoisonError::into_inner);
if st.code.is_some() || st.stop.is_some() {
break st.code.take();
}
drop(st);
self.abandon_if_idle(p);
};
if self.stopped(p) {
return;
}
let Some(code) = code else { return };
d.submit_code(&code)
}
};
}
}
fn abandon_if_idle(&self, p: &Pending) {
let idle = {
let st = p.lock();
st.stop.is_none() && self.now() - st.last_poll > self.timing.abandon_ms
};
if idle {
self.log(&format!("org {}: a login was abandoned, cancelling", p.org));
stop(p, Stop::Abandon);
}
}
fn publish(p: &Pending, view: View) {
let mut st = p.lock();
if st.view.finished() {
return;
}
if st.view != view {
st.view = view;
st.revision += 1;
}
st.busy = false;
}
fn finish(&self, p: &Pending, view: View) {
let mut st = p.lock();
if st.view.finished() {
return;
}
st.view = view;
st.revision += 1;
st.busy = false;
st.code = None;
st.finished_at = Some(self.now());
drop(st);
p.cv.notify_all();
}
fn stopped(&self, p: &Pending) -> bool {
let Some(why) = p.lock().stop else {
return false;
};
self.finish(
p,
match why {
Stop::Cancel => View::Cancelled,
Stop::Abandon => View::Expired,
},
);
true
}
}
fn stop(p: &Pending, why: Stop) {
{
let mut st = p.lock();
if st.view.finished() || st.stop.is_some() {
return;
}
st.stop = Some(why);
}
p.cv.notify_all();
}