device.rs
| 1 | //! What an app sees with its device token, and pairing an app with an account. |
| 2 | |
| 3 | use std::collections::HashMap; |
| 4 | use std::sync::Mutex; |
| 5 | |
| 6 | use api::{DeviceToken, PairBegin, PairCode, PairFinish, Person, Point}; |
| 7 | use axum::Json; |
| 8 | use axum::extract::{Query, State}; |
| 9 | use rusqlite::{Connection, params}; |
| 10 | use serde::Deserialize; |
| 11 | use sha2::{Digest, Sha256}; |
| 12 | |
| 13 | use crate::auth::{self, Device, User}; |
| 14 | use crate::routes::{Access, check_device_name, insert_device, person_for, track_points}; |
| 15 | use crate::{AppState, Error, now}; |
| 16 | |
| 17 | type Result<T> = std::result::Result<T, Error>; |
| 18 | |
| 19 | fn own_access(db: &Connection, user_id: i64) -> Result<Access> { |
| 20 | let username = db.query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| { |
| 21 | r.get(0) |
| 22 | })?; |
| 23 | Ok(Access { |
| 24 | owner: user_id, |
| 25 | username, |
| 26 | share: None, |
| 27 | all_devices: true, |
| 28 | trail_since: Some(0), |
| 29 | precision_m: 0, |
| 30 | }) |
| 31 | } |
| 32 | |
| 33 | /// The device's owner, with only this device. |
| 34 | pub async fn me(State(s): State<AppState>, d: Device) -> Result<Json<Person>> { |
| 35 | let db = s.db(); |
| 36 | let mut person = person_for(&db, own_access(&db, d.user_id)?)?; |
| 37 | person.devices.retain(|x| x.id == d.id); |
| 38 | person.default_device = person.default_device.filter(|id| *id == d.id); |
| 39 | Ok(Json(person)) |
| 40 | } |
| 41 | |
| 42 | #[derive(Deserialize)] |
| 43 | pub struct Range { |
| 44 | from: i64, |
| 45 | to: i64, |
| 46 | } |
| 47 | |
| 48 | pub async fn track( |
| 49 | State(s): State<AppState>, |
| 50 | d: Device, |
| 51 | Query(q): Query<Range>, |
| 52 | ) -> Result<Json<Vec<Point>>> { |
| 53 | let db = s.db(); |
| 54 | let a = own_access(&db, d.user_id)?; |
| 55 | Ok(Json(track_points(&db, &a, d.id, q.from, q.to)?)) |
| 56 | } |
| 57 | |
| 58 | const PAIR_SECS: i64 = 300; |
| 59 | |
| 60 | struct Pairing { |
| 61 | user_id: i64, |
| 62 | session_hash: Vec<u8>, |
| 63 | challenge: String, |
| 64 | name: String, |
| 65 | expires_at: i64, |
| 66 | } |
| 67 | |
| 68 | /// Codes from the web UI that an app can exchange for a device token, once. |
| 69 | #[derive(Default)] |
| 70 | pub struct Pairings(Mutex<HashMap<String, Pairing>>); |
| 71 | |
| 72 | impl Pairings { |
| 73 | fn put(&self, user: &User, challenge: String, name: String) -> String { |
| 74 | let (code, _) = auth::new_secret(); |
| 75 | let now = now(); |
| 76 | let mut map = self.0.lock().unwrap(); |
| 77 | map.retain(|_, p| p.expires_at > now); |
| 78 | let p = Pairing { |
| 79 | user_id: user.id, |
| 80 | session_hash: user.session_hash.clone(), |
| 81 | challenge, |
| 82 | name, |
| 83 | expires_at: now + PAIR_SECS, |
| 84 | }; |
| 85 | map.insert(code.clone(), p); |
| 86 | code |
| 87 | } |
| 88 | |
| 89 | /// The code is gone after one attempt, so a wrong verifier cannot be retried. |
| 90 | fn take(&self, code: &str, verifier: &str) -> Option<Pairing> { |
| 91 | let p = self.0.lock().unwrap().remove(code)?; |
| 92 | let challenge = auth::hex(&Sha256::digest(verifier.as_bytes())); |
| 93 | (p.expires_at > now() && challenge == p.challenge).then_some(p) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /// The app sends the SHA-256 of a secret it keeps. Only that app can then use the code. |
| 98 | pub async fn pair_begin( |
| 99 | State(s): State<AppState>, |
| 100 | user: User, |
| 101 | Json(b): Json<PairBegin>, |
| 102 | ) -> Result<Json<PairCode>> { |
| 103 | let challenge = b.challenge.to_ascii_lowercase(); |
| 104 | if challenge.len() != 64 || !challenge.bytes().all(|c| c.is_ascii_hexdigit()) { |
| 105 | return Err(Error::BadRequest( |
| 106 | "challenge must be a SHA-256 in hex".into(), |
| 107 | )); |
| 108 | } |
| 109 | let name = check_device_name(&b.name)?.to_owned(); |
| 110 | Ok(Json(PairCode { |
| 111 | code: s.pairings.put(&user, challenge, name), |
| 112 | })) |
| 113 | } |
| 114 | |
| 115 | pub async fn pair_finish( |
| 116 | State(s): State<AppState>, |
| 117 | Json(b): Json<PairFinish>, |
| 118 | ) -> Result<Json<DeviceToken>> { |
| 119 | let p = s |
| 120 | .pairings |
| 121 | .take(&b.code, &b.verifier) |
| 122 | .ok_or(Error::NotFound)?; |
| 123 | let db = s.db(); |
| 124 | // A code lives only as long as the session that began it. Sign-outs and password resets end it too. |
| 125 | let live: bool = db.query_row( |
| 126 | "SELECT EXISTS (SELECT 1 FROM sessions WHERE token_hash = ?1 AND user_id = ?2 AND expires_at > ?3)", |
| 127 | params![p.session_hash, p.user_id, now()], |
| 128 | |r| r.get(0), |
| 129 | )?; |
| 130 | if !live { |
| 131 | return Err(Error::NotFound); |
| 132 | } |
| 133 | Ok(Json(insert_device(&db, p.user_id, &p.name)?)) |
| 134 | } |
| 135 | |
| 136 | #[cfg(test)] |
| 137 | mod tests { |
| 138 | use super::*; |
| 139 | |
| 140 | #[test] |
| 141 | fn a_code_needs_its_verifier_and_works_once() { |
| 142 | let p = Pairings::default(); |
| 143 | let user = User { |
| 144 | id: 7, |
| 145 | username: "a".into(), |
| 146 | is_admin: false, |
| 147 | session_hash: vec![1], |
| 148 | signed_in_at: 0, |
| 149 | }; |
| 150 | let challenge = auth::hex(&Sha256::digest(b"secret")); |
| 151 | let code = p.put(&user, challenge.clone(), "phone".into()); |
| 152 | assert!( |
| 153 | p.take(&code, "secret") |
| 154 | .is_some_and(|p| p.user_id == 7 && p.name == "phone") |
| 155 | ); |
| 156 | assert!(p.take(&code, "secret").is_none()); |
| 157 | let code = p.put(&user, challenge, "phone".into()); |
| 158 | assert!(p.take(&code, "guess").is_none()); |
| 159 | assert!(p.take(&code, "secret").is_none()); |
| 160 | } |
| 161 | |
| 162 | #[tokio::test] |
| 163 | async fn a_code_dies_with_its_session() { |
| 164 | let s = crate::test_state(); |
| 165 | let id = crate::insert_user(&s.db(), "a", "h", false).unwrap(); |
| 166 | s.db() |
| 167 | .execute( |
| 168 | "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (x'01', ?1, 9999999999)", |
| 169 | [id], |
| 170 | ) |
| 171 | .unwrap(); |
| 172 | let user = User { |
| 173 | id, |
| 174 | username: "a".into(), |
| 175 | is_admin: false, |
| 176 | session_hash: vec![1], |
| 177 | signed_in_at: 0, |
| 178 | }; |
| 179 | let pair = |code: String| { |
| 180 | pair_finish( |
| 181 | State(s.clone()), |
| 182 | Json(PairFinish { |
| 183 | code, |
| 184 | verifier: "secret".into(), |
| 185 | }), |
| 186 | ) |
| 187 | }; |
| 188 | let challenge = auth::hex(&Sha256::digest(b"secret")); |
| 189 | let code = s.pairings.put(&user, challenge.clone(), "phone".into()); |
| 190 | assert!(pair(code).await.is_ok()); |
| 191 | let code = s.pairings.put(&user, challenge, "phone".into()); |
| 192 | crate::reset_password(&mut s.db(), id, "new").unwrap(); |
| 193 | assert!(matches!(pair(code).await, Err(Error::NotFound))); |
| 194 | } |
| 195 | } |
| 196 |