//! What an app sees with its device token, and pairing an app with an account. use std::collections::HashMap; use std::sync::Mutex; use api::{DeviceToken, PairBegin, PairCode, PairFinish, Person, Point}; use axum::Json; use axum::extract::{Query, State}; use rusqlite::{Connection, params}; use serde::Deserialize; use sha2::{Digest, Sha256}; use crate::auth::{self, Device, User}; use crate::routes::{Access, check_device_name, insert_device, person_for, track_points}; use crate::{AppState, Error, now}; type Result = std::result::Result; fn own_access(db: &Connection, user_id: i64) -> Result { let username = db.query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| { r.get(0) })?; Ok(Access { owner: user_id, username, share: None, all_devices: true, trail_since: Some(0), precision_m: 0, }) } /// The device's owner, with only this device. pub async fn me(State(s): State, d: Device) -> Result> { let db = s.db(); let mut person = person_for(&db, own_access(&db, d.user_id)?)?; person.devices.retain(|x| x.id == d.id); person.default_device = person.default_device.filter(|id| *id == d.id); Ok(Json(person)) } #[derive(Deserialize)] pub struct Range { from: i64, to: i64, } pub async fn track( State(s): State, d: Device, Query(q): Query, ) -> Result>> { let db = s.db(); let a = own_access(&db, d.user_id)?; Ok(Json(track_points(&db, &a, d.id, q.from, q.to)?)) } const PAIR_SECS: i64 = 300; struct Pairing { user_id: i64, session_hash: Vec, challenge: String, name: String, expires_at: i64, } /// Codes from the web UI that an app can exchange for a device token, once. #[derive(Default)] pub struct Pairings(Mutex>); impl Pairings { fn put(&self, user: &User, challenge: String, name: String) -> String { let (code, _) = auth::new_secret(); let now = now(); let mut map = self.0.lock().unwrap(); map.retain(|_, p| p.expires_at > now); let p = Pairing { user_id: user.id, session_hash: user.session_hash.clone(), challenge, name, expires_at: now + PAIR_SECS, }; map.insert(code.clone(), p); code } /// The code is gone after one attempt, so a wrong verifier cannot be retried. fn take(&self, code: &str, verifier: &str) -> Option { let p = self.0.lock().unwrap().remove(code)?; let challenge = auth::hex(&Sha256::digest(verifier.as_bytes())); (p.expires_at > now() && challenge == p.challenge).then_some(p) } } /// The app sends the SHA-256 of a secret it keeps. Only that app can then use the code. pub async fn pair_begin( State(s): State, user: User, Json(b): Json, ) -> Result> { let challenge = b.challenge.to_ascii_lowercase(); if challenge.len() != 64 || !challenge.bytes().all(|c| c.is_ascii_hexdigit()) { return Err(Error::BadRequest( "challenge must be a SHA-256 in hex".into(), )); } let name = check_device_name(&b.name)?.to_owned(); Ok(Json(PairCode { code: s.pairings.put(&user, challenge, name), })) } pub async fn pair_finish( State(s): State, Json(b): Json, ) -> Result> { let p = s .pairings .take(&b.code, &b.verifier) .ok_or(Error::NotFound)?; let db = s.db(); // A code lives only as long as the session that began it. Sign-outs and password resets end it too. let live: bool = db.query_row( "SELECT EXISTS (SELECT 1 FROM sessions WHERE token_hash = ?1 AND user_id = ?2 AND expires_at > ?3)", params![p.session_hash, p.user_id, now()], |r| r.get(0), )?; if !live { return Err(Error::NotFound); } Ok(Json(insert_device(&db, p.user_id, &p.name)?)) } #[cfg(test)] mod tests { use super::*; #[test] fn a_code_needs_its_verifier_and_works_once() { let p = Pairings::default(); let user = User { id: 7, username: "a".into(), is_admin: false, session_hash: vec![1], signed_in_at: 0, }; let challenge = auth::hex(&Sha256::digest(b"secret")); let code = p.put(&user, challenge.clone(), "phone".into()); assert!( p.take(&code, "secret") .is_some_and(|p| p.user_id == 7 && p.name == "phone") ); assert!(p.take(&code, "secret").is_none()); let code = p.put(&user, challenge, "phone".into()); assert!(p.take(&code, "guess").is_none()); assert!(p.take(&code, "secret").is_none()); } #[tokio::test] async fn a_code_dies_with_its_session() { let s = crate::test_state(); let id = crate::insert_user(&s.db(), "a", "h", false).unwrap(); s.db() .execute( "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (x'01', ?1, 9999999999)", [id], ) .unwrap(); let user = User { id, username: "a".into(), is_admin: false, session_hash: vec![1], signed_in_at: 0, }; let pair = |code: String| { pair_finish( State(s.clone()), Json(PairFinish { code, verifier: "secret".into(), }), ) }; let challenge = auth::hex(&Sha256::digest(b"secret")); let code = s.pairings.put(&user, challenge.clone(), "phone".into()); assert!(pair(code).await.is_ok()); let code = s.pairings.put(&user, challenge, "phone".into()); crate::reset_password(&mut s.db(), id, "new").unwrap(); assert!(matches!(pair(code).await, Err(Error::NotFound))); } }