guest.rs
⎇
Raw
1//! Guest links: a share for anyone who has the link, optionally behind a password.
2
3use api::{GuestAuth, GuestKey, GuestTrack, GuestUnlock, GuestView, Link, NewLink, Point};
4use axum::Json;
5use axum::extract::{Path, State};
6use rusqlite::{Connection, OptionalExtension, params};
7use sha2::{Digest, Sha256};
8
9use crate::auth::{self, User};
10use crate::routes::{ACCESS_COLS, Access, access_at, check_settings, save_settings, settings_at};
11use crate::{AppState, Error, now};
12
13type Result<T> = std::result::Result<T, Error>;
14
15struct 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.
22fn 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(6)?,
35 pw_hash: r.get(7)?,
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.
53fn key(token: &str, pw_hash: &str) -> String {
54 auth::hex(&Sha256::digest(format!("{token}\n{pw_hash}").as_bytes()))
55}
56
57pub 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
66pub 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
77pub 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 hash: Option<String> = s
83 .db()
84 .query_row(
85 "SELECT pw_hash FROM shares WHERE token = ?1 AND (expires_at IS NULL OR expires_at > ?2)",
86 params![b.token, now()],
87 |r| r.get(0),
88 )
89 .optional()?
90 .ok_or(Error::NotFound)?;
91 let hash = hash.ok_or_else(|| Error::BadRequest("this link has no password".into()))?;
92 let (password, h) = (b.password, hash.clone());
93 auth::limited(&s, ip, &format!("link {}", b.token), move || {
94 auth::verify_password(&password, &h).then_some(())
95 })
96 .await?;
97 Ok(Json(GuestKey {
98 key: key(&b.token, &hash),
99 }))
100}
101
102pub async fn list(State(s): State<AppState>, user: User) -> Result<Json<Vec<Link>>> {
103 let db = s.db();
104 let links = db
105 .prepare_cached(&format!(
106 "SELECT s.name, s.token, s.expires_at, s.created_at, s.pw_hash IS NOT NULL, {ACCESS_COLS}
107 FROM shares s WHERE s.owner_id = ?1 AND s.viewer_id IS NULL ORDER BY s.created_at DESC"
108 ))?
109 .query_map([user.id], |r| {
110 Ok(Link {
111 id: r.get(5)?,
112 name: r.get(0)?,
113 token: r.get(1)?,
114 expires_at: r.get(2)?,
115 created_at: r.get(3)?,
116 has_password: r.get(4)?,
117 settings: settings_at(&db, r, 5)?,
118 })
119 })?
120 .collect::<rusqlite::Result<_>>()?;
121 Ok(Json(links))
122}
123
124pub async fn create(
125 State(s): State<AppState>,
126 user: User,
127 Json(b): Json<NewLink>,
128) -> Result<Json<Link>> {
129 check_settings(&b.settings, b.expires_at)?;
130 let name = b.name.trim().to_owned();
131 if name.chars().count() > 100 {
132 return Err(Error::BadRequest(
133 "the name can have at most 100 characters".into(),
134 ));
135 }
136 let pw_hash = match b.password.filter(|p| !p.is_empty()) {
137 Some(p) => {
138 auth::check_new_password(&p).map_err(|m| Error::BadRequest(m.into()))?;
139 Some(auth::hash_password_async(p).await?)
140 }
141 None => None,
142 };
143 let (token, _) = auth::new_secret();
144 let now = now();
145 let mut db = s.db();
146 let tx = db.transaction()?;
147 tx.execute(
148 "INSERT INTO shares (owner_id, expires_at, created_at, name, token, pw_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
149 params![user.id, b.expires_at, now, name, token, pw_hash],
150 )?;
151 let id = tx.last_insert_rowid();
152 save_settings(&tx, id, user.id, &b.settings)?;
153 tx.commit()?;
154 Ok(Json(Link {
155 id,
156 name,
157 token,
158 expires_at: b.expires_at,
159 created_at: now,
160 has_password: pw_hash.is_some(),
161 settings: b.settings,
162 }))
163}
164
165pub async fn delete(
166 State(s): State<AppState>,
167 user: User,
168 Path(id): Path<i64>,
169) -> Result<Json<()>> {
170 let n = s.db().execute(
171 "DELETE FROM shares WHERE id = ?1 AND owner_id = ?2 AND viewer_id IS NULL",
172 [id, user.id],
173 )?;
174 if n == 0 {
175 return Err(Error::NotFound);
176 }
177 Ok(Json(()))
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn password_links_need_the_key() {
186 let db = crate::test_db();
187 db.execute_batch(
188 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
189 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
190 INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 100, 0, 0), (1, 200, 0, 0);
191 INSERT INTO shares (owner_id, created_at, token, pw_hash, trail_since) VALUES (1, 0, 'open', NULL, 150), (1, 0, 'locked', 'h', NULL);
192 INSERT INTO shares (owner_id, created_at, token, expires_at) VALUES (1, 0, 'old', 1);",
193 )
194 .unwrap();
195 let auth = |token: &str, key: Option<String>| GuestAuth {
196 token: token.into(),
197 key,
198 };
199 let g = open(&db, &auth("open", None)).unwrap();
200 assert_eq!(g.access.trail_since, Some(150));
201 let points = crate::routes::track_points(&db, &g.access, 1, 0, 300).unwrap();
202 assert_eq!(points.iter().map(|p| p.ts).collect::<Vec<_>>(), [200]);
203 assert!(matches!(
204 open(&db, &auth("locked", None)),
205 Err(Error::Unauthorized)
206 ));
207 assert!(matches!(
208 open(&db, &auth("locked", Some("x".into()))),
209 Err(Error::Unauthorized)
210 ));
211 assert!(open(&db, &auth("locked", Some(key("locked", "h")))).is_ok());
212 assert!(matches!(
213 open(&db, &auth("old", None)),
214 Err(Error::NotFound)
215 ));
216 assert!(matches!(
217 open(&db, &auth("nope", None)),
218 Err(Error::NotFound)
219 ));
220 }
221}
222