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