From 2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328 Mon Sep 17 00:00:00 2001 From: pennae Date: Wed, 13 Jul 2022 10:33:30 +0200 Subject: initial import --- src/api/auth/device.rs | 455 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 src/api/auth/device.rs (limited to 'src/api/auth/device.rs') diff --git a/src/api/auth/device.rs b/src/api/auth/device.rs new file mode 100644 index 0000000..2b05e12 --- /dev/null +++ b/src/api/auth/device.rs @@ -0,0 +1,455 @@ +use std::time::Duration; +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Utc}; +use futures::future::join_all; +use rocket::{serde::json::Json, State}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::auth::{WithSession, WithVerifiedFxaLogin, WithVerifiedSession}; +use crate::api::{Empty, EMPTY}; +use crate::db::DbConn; +use crate::push::PushClient; +use crate::utils::DeferAction; +use crate::{ + api::{auth, serialize_dt_opt}, + auth::Authenticated, + db::Db, + types::{ + Device, DeviceCommand, DeviceCommands, DeviceID, DevicePush, DeviceUpdate, OauthTokenID, + SessionID, + }, +}; + +fn map_error(e: sqlx::Error) -> auth::Error { + match &e { + // not-null violations can presumably only be caused by bad parameters + sqlx::Error::Database(de) if de.code().as_deref() == Some("23502") => { + auth::Error::MissingParameter + }, + sqlx::Error::RowNotFound => auth::Error::UnknownDevice, + _ => auth::Error::Other(anyhow!(e)), + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct Info { + isCurrentDevice: bool, + id: DeviceID, + lastAccessTime: i64, + name: String, + r#type: String, + pushCallback: Option, + pushPublicKey: Option, + pushAuthKey: Option, + pushEndpointExpired: bool, + availableCommands: HashMap, + // NOTE location is optional per the spec, but fenix crashes if it isn't present + location: Value, + // MISSING lastAccessTimeFormatted + // MISSING approximateLastAccessTime + // MISSING approximateLastAccessTimeFormatted +} + +fn device_to_json(current: Option<&DeviceID>, dev: Device) -> Info { + let (pcb, ppk, pak) = match dev.push { + Some(p) => (Some(p.callback), Some(p.public_key), Some(p.auth_key)), + None => (None, None, None), + }; + Info { + isCurrentDevice: Some(&dev.device_id) == current, + id: dev.device_id, + lastAccessTime: dev.last_active.timestamp(), + name: dev.name, + r#type: dev.type_, + pushCallback: pcb, + pushPublicKey: ppk, + pushAuthKey: pak, + pushEndpointExpired: dev.push_expired, + availableCommands: dev.available_commands.into_map(), + location: dev.location, + } +} + +#[derive(Serialize, Deserialize, PartialEq)] +#[serde(transparent)] +pub(crate) struct ListResp(Vec); + +#[get("/account/devices")] +pub(crate) async fn devices( + db: &DbConn, + auth: Authenticated<(), WithVerifiedSession>, +) -> auth::Result { + let devs = db.get_devices(&auth.context.uid).await?; + Ok(Json(ListResp( + devs.into_iter().map(|dev| device_to_json(auth.context.device_id.as_ref(), dev)).collect(), + ))) +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct DeviceReq { + id: Option, + name: Option, + r#type: Option, + pushCallback: Option, + pushPublicKey: Option, + pushAuthKey: Option, + availableCommands: Option>, + // present for legacy reasons, ignored + #[allow(dead_code)] + capabilities: Option>, + location: Option, +} + +#[post("/account/device", data = "")] +pub(crate) async fn device( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + // need to allow registrations to all sessions, otherwise the "now verified" + // notification can't be sent + data: Authenticated, +) -> auth::Result { + let dev = data.body; + if let (None, None, None) = (&dev.name, &dev.r#type, &dev.pushCallback) { + return Err(auth::Error::MissingParameter); + } + + let push = dev.pushCallback.map(|pcb| DevicePush { + callback: pcb, + public_key: dev.pushPublicKey.unwrap_or_default(), + auth_key: dev.pushAuthKey.unwrap_or_default(), + }); + + let (own_id, changed_id, notify) = match (dev.id, data.context.device_id) { + (None, None) => { + let new = DeviceID::random(); + (Some(new.clone()), new, true) + }, + (None, Some(own)) => (Some(own.clone()), own, false), + (Some(other), own) => (own, other, false), + }; + let result = db + .change_device( + &data.context.uid, + &changed_id, + DeviceUpdate { + name: dev.name.as_ref().map(AsRef::as_ref), + type_: dev.r#type.as_ref().map(AsRef::as_ref), + push, + available_commands: dev.availableCommands.map(DeviceCommands), + location: dev.location, + }, + ) + .await + .map_err(map_error)?; + if notify { + db.set_session_device(&data.session, Some(&changed_id)).await?; + match db.get_devices(&data.context.uid).await { + Err(e) => warn!("device_connected push failed: {e}"), + Ok(mut devs) => defer.spawn_after_success("api::auth/account/device(post)", { + devs.retain(|d| d.device_id != changed_id); + let (client, db) = (Arc::clone(client), db_pool.clone()); + let name = result.name.clone(); + async move { + let db = db.begin().await?; + client.device_connected(&db, &devs, &name).await; + db.commit().await?; + Ok(()) + } + }), + }; + } + Ok(Json(device_to_json(own_id.as_ref(), result))) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Command { + target: DeviceID, + command: String, + payload: Value, + ttl: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct InvokeResp { + enqueued: bool, + notified: bool, + notifyError: Option, +} + +// NOTE fenix doesn't register a push callback for some reason, so receiving tabs +// always requires opening the tab share menu or tab list first. +#[post("/account/devices/invoke_command", data = "")] +pub(crate) async fn invoke( + client: &State>, + db: &DbConn, + cmd: Authenticated, +) -> auth::Result { + let sender = cmd.context.device_id; + let dev = db.get_device(&cmd.context.uid, &cmd.body.target).await.map_err(map_error)?; + if dev.available_commands.get(&cmd.body.command).is_none() { + return Err(auth::Error::NoDeviceCommand); + } + let ttl = cmd.body.ttl.unwrap_or(30 * 86400).clamp(60, 30 * 86400); + let idx = db + .enqueue_command(&cmd.body.target, &sender, &cmd.body.command, &cmd.body.payload, ttl) + .await?; + let (notified, error) = client + .command_received(db, &dev, &cmd.body.command, idx, &sender) + .await + .map_or_else(|e| (false, Some(e.to_string())), |_| (true, None)); + Ok(Json(InvokeResp { enqueued: true, notified, notifyError: error })) +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandData { + command: String, + payload: Value, + sender: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandsEntry { + index: i64, + data: CommandData, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandsResp { + index: i64, + last: bool, + messages: Vec, +} + +fn map_command(c: DeviceCommand) -> CommandsEntry { + CommandsEntry { + index: c.index, + data: CommandData { command: c.command, payload: c.payload, sender: c.sender }, + } +} + +#[get("/account/device/commands?&")] +pub(crate) async fn commands( + db: &DbConn, + index: i64, + limit: Option, + auth: Authenticated<(), WithVerifiedSession>, +) -> auth::Result { + let dev = auth.context.device_id.as_ref().ok_or(auth::Error::UnknownDevice)?; + let (more, cmds) = + db.get_commands(&auth.context.uid, dev, index, limit.unwrap_or(100).clamp(0, 100)).await?; + Ok(Json(CommandsResp { + index: cmds.iter().map(|c| c.index).max().unwrap_or(0), + last: !more, + messages: cmds.into_iter().map(map_command).collect(), + })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DestroyReq { + id: DeviceID, +} + +#[post("/account/device/destroy", data = "")] +pub(crate) async fn destroy( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + req: crate::auth::Authenticated, +) -> auth::Result { + db.delete_device(&req.context.uid, &req.body.id).await.map_err(map_error)?; + match db.get_devices(&req.context.uid).await { + Err(e) => warn!("device_disconnected push failed: {e}"), + Ok(devs) => defer.spawn_after_success("api::auth/account/device/destroy(post)", { + let (client, db) = (Arc::clone(client), db_pool.clone()); + async move { + let db = db.begin().await?; + client.device_disconnected(&db, &devs, &req.body.id).await; + db.commit().await?; + Ok(()) + } + }), + }; + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +pub(crate) enum NotifyTarget { + #[serde(rename = "all")] + All, +} + +#[derive(Debug, Deserialize)] +pub(crate) enum NotifyEPAction { + #[serde(rename = "accountVerify")] + AccountVerify, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +#[serde(untagged, deny_unknown_fields)] +pub(crate) enum NotifyReq { + // deny_unknown_fields and flatten don't work together + All { + #[allow(dead_code)] + to: NotifyTarget, + _endpointAction: Option, + excluded: Option>, + payload: Value, + TTL: Option, + }, + Some { + to: Vec, + _endpointAction: Option, + payload: Value, + TTL: Option, + }, +} + +#[post("/account/devices/notify", data = "")] +pub(crate) async fn notify( + db: &DbConn, + client: &State>, + req: Authenticated, +) -> auth::Result { + let (to, payload, ttl) = match req.body { + NotifyReq::All { excluded, payload, TTL: ttl, .. } => { + let excluded = excluded.unwrap_or_default(); + let mut devs = db.get_devices(&req.context.uid).await?; + devs.retain(|d| !excluded.contains(&d.device_id)); + (devs, payload, ttl) + }, + NotifyReq::Some { to, payload, TTL: ttl, .. } => { + let to = join_all(to.iter().map(|id| db.get_device(&req.context.uid, id))) + .await + .into_iter() + .collect::, _>>()?; + (to, payload, ttl) + }, + }; + client.push_any(db, &to, Duration::from_secs(ttl.unwrap_or(0).into()), payload).await; + Ok(EMPTY) +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct AttachedClient { + clientId: Option, + deviceId: Option, + sessionTokenId: Option, + refreshTokenId: Option, + isCurrentSession: bool, + deviceType: Option, + name: Option, + #[serde(serialize_with = "serialize_dt_opt")] + createdTime: Option>, + // MISSING createdTimeFormatted + #[serde(serialize_with = "serialize_dt_opt")] + lastAccessTime: Option>, + // MISSING lastAccessTimeFormatted + // MISSING approximateLastAccessTime + // MISSING approximateLastAccessTimeFormatted + scope: Option, + // MISSING location + // MISSING userAgent + // MISSING os +} + +// MISSING filterIdleDevicesTimestamp +#[get("/account/attached_clients")] +pub(crate) async fn attached_clients( + db: &DbConn, + auth: Authenticated<(), WithVerifiedFxaLogin>, +) -> auth::Result> { + let clients = db.get_attached_clients(&auth.context.uid).await?; + Ok(Json( + clients + .into_iter() + .map(|dev| AttachedClient { + clientId: dev.client_id, + deviceId: dev.device_id, + refreshTokenId: dev.refresh_token_id, + isCurrentSession: dev.session_token_id.as_ref() == Some(&auth.session), + sessionTokenId: dev.session_token_id, + deviceType: dev.device_type, + name: dev.name, + createdTime: dev.created_time, + lastAccessTime: dev.last_access_time, + scope: dev.scope, + }) + .collect::>(), + )) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct DestroyAttachedClientReq { + // NOTE should be used to verify token deletion, but since we allow only a fixed + // number of clients that makes little sense. + #[allow(dead_code)] + clientId: Option, + sessionTokenId: Option, + refreshTokenId: Option, + deviceId: Option, +} + +#[post("/account/attached_client/destroy", data = "")] +pub(crate) async fn destroy_attached_client( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + req: Authenticated, +) -> auth::Result { + // only one id may be given, otherwise deleting things properly is more work. + if (req.body.sessionTokenId.is_some() as u32) + + (req.body.refreshTokenId.is_some() as u32) + + (req.body.deviceId.is_some() as u32) + != 1 + { + return Err(auth::Error::InvalidParameter); + } + + if let Some(dev) = req.body.deviceId { + let devs = db.get_devices(&req.context.uid).await; + db.delete_device(&req.context.uid, &dev).await?; + match devs { + Err(e) => warn!("device_disconnected push failed: {e}"), + Ok(devs) => { + defer.spawn_after_success("api::auth/account/attached_client/destroy(post)", { + let (client, db) = (Arc::clone(client), db_pool.clone()); + async move { + let db = db.begin().await?; + client.device_disconnected(&db, &devs, &dev).await; + db.commit().await?; + Ok(()) + } + }) + }, + }; + } + if let Some(id) = req.body.sessionTokenId { + db.delete_session(&req.context.uid, &id).await?; + } + if let Some(id) = req.body.refreshTokenId { + db.delete_refresh_token(&id).await?; + } + + Ok(EMPTY) +} -- cgit v1.2.3