guest.rs
| 1 | //! Guest links: a share for anyone who has the link, optionally behind a password. |
| 2 | |
| 3 | use api::{GuestAuth, GuestKey, GuestTrack, GuestUnlock, GuestView, Link, NewLink, Point}; |
| 4 | use axum::Json; |
| 5 | use axum::extract::{Path, State}; |
| 6 | use rusqlite::{Connection, OptionalExtension, params}; |
| 7 | use sha2::{Digest, Sha256}; |
| 8 | |
| 9 | use crate::auth::{self, User}; |
| 10 | use crate::routes::{ACCESS_COLS, Access, access_at, check_settings, save_settings, settings_at}; |
| 11 | use crate::{AppState, Error, now}; |
| 12 | |
| 13 | type Result<T> = std::result::Result<T, Error>; |
| 14 | |
| 15 | struct Guest { |
| 16 | access: Access, |
| 17 | expires_at: Option<i64>, |
| 18 | pw_hash: Option<String>, |
| 19 | } |
| 20 | |
| 21 | /// An unknown or expired token is 404, a missing or wrong key 401. |
| 22 | fn open(db: &Connection, auth: &GuestAuth) -> Result<Guest> { |
| 23 | let guest = db |
| 24 | .query_row( |
| 25 | &format!( |
| 26 | "SELECT s.owner_id, u.username, {ACCESS_COLS}, s.expires_at, s.pw_hash |
| 27 | FROM shares s JOIN users u ON u.id = s.owner_id |
| 28 | WHERE s.token = ?1 AND (s.expires_at IS NULL OR s.expires_at > ?2)" |
| 29 | ), |
| 30 | params![auth.token, now()], |
| 31 | |r| { |
| 32 | Ok(Guest { |
| 33 | access: access_at(r)?, |
| 34 | expires_at: r.get(7)?, |
| 35 | pw_hash: r.get(8)?, |
| 36 | }) |
| 37 | }, |
| 38 | ) |
| 39 | .optional()? |
| 40 | .ok_or(Error::NotFound)?; |
| 41 | if let Some(hash) = &guest.pw_hash { |
| 42 | let expected = key(&auth.token, hash); |
| 43 | // Comparing digests keeps the timing independent of how much of the key is right. |
| 44 | let digest = |s: &str| Sha256::digest(s.as_bytes()); |
| 45 | if auth.key.as_deref().map(digest) != Some(digest(&expected)) { |
| 46 | return Err(Error::Unauthorized); |
| 47 | } |
| 48 | } |
| 49 | Ok(guest) |
| 50 | } |
| 51 | |
| 52 | /// The password hash carries a random salt, so only the server can derive this. |
| 53 | fn key(token: &str, pw_hash: &str) -> String { |
| 54 | hex::encode(Sha256::digest(format!("{token}\n{pw_hash}").as_bytes())) |
| 55 | } |
| 56 | |
| 57 | pub async fn view(State(s): State<AppState>, Json(b): Json<GuestAuth>) -> Result<Json<GuestView>> { |
| 58 | let db = s.db(); |
| 59 | let g = open(&db, &b)?; |
| 60 | Ok(Json(GuestView { |
| 61 | expires_at: g.expires_at, |
| 62 | person: crate::routes::person_for(&db, g.access)?, |
| 63 | })) |
| 64 | } |
| 65 | |
| 66 | pub async fn track( |
| 67 | State(s): State<AppState>, |
| 68 | Json(b): Json<GuestTrack>, |
| 69 | ) -> Result<Json<Vec<Point>>> { |
| 70 | let db = s.db(); |
| 71 | let g = open(&db, &b.auth)?; |
| 72 | Ok(Json(crate::routes::track_points( |
| 73 | &db, &g.access, b.device, b.from, b.to, |
| 74 | )?)) |
| 75 | } |
| 76 | |
| 77 | pub async fn unlock( |
| 78 | State(s): State<AppState>, |
| 79 | auth::ClientIp(ip): auth::ClientIp, |
| 80 | Json(b): Json<GuestUnlock>, |
| 81 | ) -> Result<Json<GuestKey>> { |
| 82 | let auth = GuestAuth { |
| 83 | token: b.token, |
| 84 | key: None, |
| 85 | }; |
| 86 | let opened = open(&s.db(), &auth); |
| 87 | let hash = match opened { |
| 88 | Ok(_) => return Err(Error::BadRequest("this link has no password".into())), |
| 89 | Err(Error::Unauthorized) => s.db().query_row( |
| 90 | "SELECT pw_hash FROM shares WHERE token = ?1", |
| 91 | [&auth.token], |
| 92 | |r| r.get::<_, String>(0), |
| 93 | )?, |
| 94 | Err(e) => return Err(e), |
| 95 | }; |
| 96 | let (password, h) = (b.password, hash.clone()); |
| 97 | auth::limited(&s, ip, &format!("link {}", auth.token), move || { |
| 98 | auth::verify_password(&password, &h).then_some(()) |
| 99 | }) |
| 100 | .await?; |
| 101 | Ok(Json(GuestKey { |
| 102 | key: key(&auth.token, &hash), |
| 103 | })) |
| 104 | } |
| 105 | |
| 106 | pub async fn list(State(s): State<AppState>, user: User) -> Result<Json<Vec<Link>>> { |
| 107 | let db = s.db(); |
| 108 | let links = db |
| 109 | .prepare_cached(&format!( |
| 110 | "SELECT s.name, s.token, s.expires_at, s.created_at, s.pw_hash IS NOT NULL, {ACCESS_COLS} |
| 111 | FROM shares s WHERE s.owner_id = ?1 AND s.viewer_id IS NULL ORDER BY s.created_at DESC" |
| 112 | ))? |
| 113 | .query_map([user.id], |r| { |
| 114 | Ok(Link { |
| 115 | id: r.get(5)?, |
| 116 | name: r.get(0)?, |
| 117 | token: r.get(1)?, |
| 118 | expires_at: r.get(2)?, |
| 119 | created_at: r.get(3)?, |
| 120 | has_password: r.get(4)?, |
| 121 | settings: settings_at(&db, r, 5)?, |
| 122 | }) |
| 123 | })? |
| 124 | .collect::<rusqlite::Result<_>>()?; |
| 125 | Ok(Json(links)) |
| 126 | } |
| 127 | |
| 128 | pub async fn create( |
| 129 | State(s): State<AppState>, |
| 130 | user: User, |
| 131 | Json(b): Json<NewLink>, |
| 132 | ) -> Result<Json<Link>> { |
| 133 | check_settings(&b.settings, b.expires_at)?; |
| 134 | let name = b.name.trim().to_owned(); |
| 135 | if name.chars().count() > 100 { |
| 136 | return Err(Error::BadRequest( |
| 137 | "the name can have at most 100 characters".into(), |
| 138 | )); |
| 139 | } |
| 140 | let pw_hash = match b.password.filter(|p| !p.is_empty()) { |
| 141 | Some(p) => { |
| 142 | auth::check_new_password(&p).map_err(|m| Error::BadRequest(m.into()))?; |
| 143 | Some(auth::hash_password_async(p).await?) |
| 144 | } |
| 145 | None => None, |
| 146 | }; |
| 147 | let (token, _) = auth::new_secret(); |
| 148 | let now = now(); |
| 149 | let mut db = s.db(); |
| 150 | let tx = db.transaction()?; |
| 151 | tx.execute( |
| 152 | "INSERT INTO shares (owner_id, expires_at, created_at, name, token, pw_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", |
| 153 | params![user.id, b.expires_at, now, name, token, pw_hash], |
| 154 | )?; |
| 155 | let id = tx.last_insert_rowid(); |
| 156 | save_settings(&tx, id, user.id, &b.settings)?; |
| 157 | tx.commit()?; |
| 158 | Ok(Json(Link { |
| 159 | id, |
| 160 | name, |
| 161 | token, |
| 162 | expires_at: b.expires_at, |
| 163 | created_at: now, |
| 164 | has_password: pw_hash.is_some(), |
| 165 | settings: b.settings, |
| 166 | })) |
| 167 | } |
| 168 | |
| 169 | pub async fn delete( |
| 170 | State(s): State<AppState>, |
| 171 | user: User, |
| 172 | Path(id): Path<i64>, |
| 173 | ) -> Result<Json<()>> { |
| 174 | let n = s.db().execute( |
| 175 | "DELETE FROM shares WHERE id = ?1 AND owner_id = ?2 AND viewer_id IS NULL", |
| 176 | [id, user.id], |
| 177 | )?; |
| 178 | if n == 0 { |
| 179 | return Err(Error::NotFound); |
| 180 | } |
| 181 | Ok(Json(())) |
| 182 | } |
| 183 | |
| 184 | #[cfg(test)] |
| 185 | mod tests { |
| 186 | use super::*; |
| 187 | use api::Trail; |
| 188 | |
| 189 | #[test] |
| 190 | fn password_links_need_the_key() { |
| 191 | let db = crate::test_db(); |
| 192 | db.execute_batch( |
| 193 | "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0); |
| 194 | INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0); |
| 195 | INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 100, 0, 0), (1, 200, 0, 0); |
| 196 | INSERT INTO shares (owner_id, created_at, token, pw_hash, trail_since) VALUES (1, 0, 'open', NULL, 150), (1, 0, 'locked', 'h', NULL); |
| 197 | INSERT INTO shares (owner_id, created_at, token, expires_at) VALUES (1, 0, 'old', 1);", |
| 198 | ) |
| 199 | .unwrap(); |
| 200 | let auth = |token: &str, key: Option<String>| GuestAuth { |
| 201 | token: token.into(), |
| 202 | key, |
| 203 | }; |
| 204 | let g = open(&db, &auth("open", None)).unwrap(); |
| 205 | assert_eq!(g.access.trail, Trail::Since(150)); |
| 206 | let points = crate::routes::track_points(&db, &g.access, 1, 0, 300).unwrap(); |
| 207 | assert_eq!(points.iter().map(|p| p.ts).collect::<Vec<_>>(), [200]); |
| 208 | assert!(matches!( |
| 209 | open(&db, &auth("locked", None)), |
| 210 | Err(Error::Unauthorized) |
| 211 | )); |
| 212 | assert!(matches!( |
| 213 | open(&db, &auth("locked", Some("x".into()))), |
| 214 | Err(Error::Unauthorized) |
| 215 | )); |
| 216 | assert!(open(&db, &auth("locked", Some(key("locked", "h")))).is_ok()); |
| 217 | assert!(matches!( |
| 218 | open(&db, &auth("old", None)), |
| 219 | Err(Error::NotFound) |
| 220 | )); |
| 221 | assert!(matches!( |
| 222 | open(&db, &auth("nope", None)), |
| 223 | Err(Error::NotFound) |
| 224 | )); |
| 225 | } |
| 226 | } |
| 227 |