//! Guest links: a share for anyone who has the link, optionally behind a password. use api::{GuestAuth, GuestKey, GuestTrack, GuestUnlock, GuestView, Link, NewLink, Point}; use axum::Json; use axum::extract::{Path, State}; use rusqlite::{Connection, OptionalExtension, params}; use sha2::{Digest, Sha256}; use crate::auth::{self, User}; use crate::routes::{ACCESS_COLS, Access, access_at, check_settings, save_settings, settings_at}; use crate::{AppState, Error, now}; type Result = std::result::Result; struct Guest { access: Access, expires_at: Option, pw_hash: Option, } /// An unknown or expired token is 404, a missing or wrong key 401. fn open(db: &Connection, auth: &GuestAuth) -> Result { let guest = db .query_row( &format!( "SELECT s.owner_id, u.username, {ACCESS_COLS}, s.expires_at, s.pw_hash FROM shares s JOIN users u ON u.id = s.owner_id WHERE s.token = ?1 AND (s.expires_at IS NULL OR s.expires_at > ?2)" ), params![auth.token, now()], |r| { Ok(Guest { access: access_at(r)?, expires_at: r.get(6)?, pw_hash: r.get(7)?, }) }, ) .optional()? .ok_or(Error::NotFound)?; if let Some(hash) = &guest.pw_hash { let expected = key(&auth.token, hash); // Comparing digests keeps the timing independent of how much of the key is right. let digest = |s: &str| Sha256::digest(s.as_bytes()); if auth.key.as_deref().map(digest) != Some(digest(&expected)) { return Err(Error::Unauthorized); } } Ok(guest) } /// The password hash carries a random salt, so only the server can derive this. fn key(token: &str, pw_hash: &str) -> String { auth::hex(&Sha256::digest(format!("{token}\n{pw_hash}").as_bytes())) } pub async fn view(State(s): State, Json(b): Json) -> Result> { let db = s.db(); let g = open(&db, &b)?; Ok(Json(GuestView { expires_at: g.expires_at, person: crate::routes::person_for(&db, g.access)?, })) } pub async fn track( State(s): State, Json(b): Json, ) -> Result>> { let db = s.db(); let g = open(&db, &b.auth)?; Ok(Json(crate::routes::track_points( &db, &g.access, b.device, b.from, b.to, )?)) } pub async fn unlock( State(s): State, auth::ClientIp(ip): auth::ClientIp, Json(b): Json, ) -> Result> { let hash: Option = s .db() .query_row( "SELECT pw_hash FROM shares WHERE token = ?1 AND (expires_at IS NULL OR expires_at > ?2)", params![b.token, now()], |r| r.get(0), ) .optional()? .ok_or(Error::NotFound)?; let hash = hash.ok_or_else(|| Error::BadRequest("this link has no password".into()))?; let (password, h) = (b.password, hash.clone()); auth::limited(&s, ip, &format!("link {}", b.token), move || { auth::verify_password(&password, &h).then_some(()) }) .await?; Ok(Json(GuestKey { key: key(&b.token, &hash), })) } pub async fn list(State(s): State, user: User) -> Result>> { let db = s.db(); let links = db .prepare_cached(&format!( "SELECT s.name, s.token, s.expires_at, s.created_at, s.pw_hash IS NOT NULL, {ACCESS_COLS} FROM shares s WHERE s.owner_id = ?1 AND s.viewer_id IS NULL ORDER BY s.created_at DESC" ))? .query_map([user.id], |r| { Ok(Link { id: r.get(5)?, name: r.get(0)?, token: r.get(1)?, expires_at: r.get(2)?, created_at: r.get(3)?, has_password: r.get(4)?, settings: settings_at(&db, r, 5)?, }) })? .collect::>()?; Ok(Json(links)) } pub async fn create( State(s): State, user: User, Json(b): Json, ) -> Result> { check_settings(&b.settings, b.expires_at)?; let name = b.name.trim().to_owned(); if name.chars().count() > 100 { return Err(Error::BadRequest( "the name can have at most 100 characters".into(), )); } let pw_hash = match b.password.filter(|p| !p.is_empty()) { Some(p) => { auth::check_new_password(&p).map_err(|m| Error::BadRequest(m.into()))?; Some(auth::hash_password_async(p).await?) } None => None, }; let (token, _) = auth::new_secret(); let now = now(); let mut db = s.db(); let tx = db.transaction()?; tx.execute( "INSERT INTO shares (owner_id, expires_at, created_at, name, token, pw_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![user.id, b.expires_at, now, name, token, pw_hash], )?; let id = tx.last_insert_rowid(); save_settings(&tx, id, user.id, &b.settings)?; tx.commit()?; Ok(Json(Link { id, name, token, expires_at: b.expires_at, created_at: now, has_password: pw_hash.is_some(), settings: b.settings, })) } pub async fn delete( State(s): State, user: User, Path(id): Path, ) -> Result> { let n = s.db().execute( "DELETE FROM shares WHERE id = ?1 AND owner_id = ?2 AND viewer_id IS NULL", [id, user.id], )?; if n == 0 { return Err(Error::NotFound); } Ok(Json(())) } #[cfg(test)] mod tests { use super::*; #[test] fn password_links_need_the_key() { let db = crate::test_db(); db.execute_batch( "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0); INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0); INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 100, 0, 0), (1, 200, 0, 0); INSERT INTO shares (owner_id, created_at, token, pw_hash, trail_since) VALUES (1, 0, 'open', NULL, 150), (1, 0, 'locked', 'h', NULL); INSERT INTO shares (owner_id, created_at, token, expires_at) VALUES (1, 0, 'old', 1);", ) .unwrap(); let auth = |token: &str, key: Option| GuestAuth { token: token.into(), key, }; let g = open(&db, &auth("open", None)).unwrap(); assert_eq!(g.access.trail_since, Some(150)); let points = crate::routes::track_points(&db, &g.access, 1, 0, 300).unwrap(); assert_eq!(points.iter().map(|p| p.ts).collect::>(), [200]); assert!(matches!( open(&db, &auth("locked", None)), Err(Error::Unauthorized) )); assert!(matches!( open(&db, &auth("locked", Some("x".into()))), Err(Error::Unauthorized) )); assert!(open(&db, &auth("locked", Some(key("locked", "h")))).is_ok()); assert!(matches!( open(&db, &auth("old", None)), Err(Error::NotFound) )); assert!(matches!( open(&db, &auth("nope", None)), Err(Error::NotFound) )); } }