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 hex::encode(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 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
106pub 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
128pub 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
169pub 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)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn password_links_need_the_key() {
190 let db = crate::test_db();
191 db.execute_batch(
192 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
193 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
194 INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 100, 0, 0), (1, 200, 0, 0);
195 INSERT INTO shares (owner_id, created_at, token, pw_hash, trail_since) VALUES (1, 0, 'open', NULL, 150), (1, 0, 'locked', 'h', NULL);
196 INSERT INTO shares (owner_id, created_at, token, expires_at) VALUES (1, 0, 'old', 1);",
197 )
198 .unwrap();
199 let auth = |token: &str, key: Option<String>| GuestAuth {
200 token: token.into(),
201 key,
202 };
203 let g = open(&db, &auth("open", None)).unwrap();
204 assert_eq!(g.access.trail_since, Some(150));
205 let points = crate::routes::track_points(&db, &g.access, 1, 0, 300).unwrap();
206 assert_eq!(points.iter().map(|p| p.ts).collect::<Vec<_>>(), [200]);
207 assert!(matches!(
208 open(&db, &auth("locked", None)),
209 Err(Error::Unauthorized)
210 ));
211 assert!(matches!(
212 open(&db, &auth("locked", Some("x".into()))),
213 Err(Error::Unauthorized)
214 ));
215 assert!(open(&db, &auth("locked", Some(key("locked", "h")))).is_ok());
216 assert!(matches!(
217 open(&db, &auth("old", None)),
218 Err(Error::NotFound)
219 ));
220 assert!(matches!(
221 open(&db, &auth("nope", None)),
222 Err(Error::NotFound)
223 ));
224 }
225}
226