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(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.
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 Json(b): Json<GuestUnlock>,
80) -> Result<Json<GuestKey>> {
81 let auth = GuestAuth {
82 token: b.token,
83 key: None,
84 };
85 let opened = open(&s.db(), &auth);
86 let hash = match opened {
87 Ok(_) => return Err(Error::BadRequest("this link has no password".into())),
88 Err(Error::Unauthorized) => s.db().query_row(
89 "SELECT pw_hash FROM shares WHERE token = ?1",
90 [&auth.token],
91 |r| r.get::<_, String>(0),
92 )?,
93 Err(e) => return Err(e),
94 };
95 // The prefix keeps link limits apart from username limits.
96 let limit = format!("\0link {}", auth.token);
97 s.limiter.check(&limit)?;
98 let (password, h) = (b.password, hash.clone());
99 let ok = tokio::task::spawn_blocking(move || auth::verify_password(&password, &h))
100 .await
101 .map_err(|e| Error::Internal(e.to_string()))?;
102 if !ok {
103 s.limiter.fail(&limit);
104 return Err(Error::Unauthorized);
105 }
106 s.limiter.clear(&limit);
107 Ok(Json(GuestKey {
108 key: key(&auth.token, &hash),
109 }))
110}
111
112pub async fn list(State(s): State<AppState>, user: User) -> Result<Json<Vec<Link>>> {
113 let db = s.db();
114 let links = db
115 .prepare_cached(&format!(
116 "SELECT s.name, s.token, s.expires_at, s.created_at, s.pw_hash IS NOT NULL, {ACCESS_COLS}
117 FROM shares s WHERE s.owner_id = ?1 AND s.viewer_id IS NULL ORDER BY s.created_at DESC"
118 ))?
119 .query_map([user.id], |r| {
120 Ok(Link {
121 id: r.get(5)?,
122 name: r.get(0)?,
123 token: r.get(1)?,
124 expires_at: r.get(2)?,
125 created_at: r.get(3)?,
126 has_password: r.get(4)?,
127 settings: settings_at(&db, r, 5)?,
128 })
129 })?
130 .collect::<rusqlite::Result<_>>()?;
131 Ok(Json(links))
132}
133
134pub async fn create(
135 State(s): State<AppState>,
136 user: User,
137 Json(b): Json<NewLink>,
138) -> Result<Json<Link>> {
139 check_settings(&b.settings, b.expires_at)?;
140 let name = b.name.trim().to_owned();
141 if name.chars().count() > 100 {
142 return Err(Error::BadRequest(
143 "the name can have at most 100 characters".into(),
144 ));
145 }
146 let pw_hash = match b.password.filter(|p| !p.is_empty()) {
147 Some(p) => {
148 auth::check_new_password(&p).map_err(|m| Error::BadRequest(m.into()))?;
149 Some(auth::hash_password_async(p).await?)
150 }
151 None => None,
152 };
153 let (token, _) = auth::new_secret();
154 let now = now();
155 let mut db = s.db();
156 let tx = db.transaction()?;
157 tx.execute(
158 "INSERT INTO shares (owner_id, expires_at, created_at, name, token, pw_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
159 params![user.id, b.expires_at, now, name, token, pw_hash],
160 )?;
161 let id = tx.last_insert_rowid();
162 save_settings(&tx, id, user.id, &b.settings)?;
163 tx.commit()?;
164 Ok(Json(Link {
165 id,
166 name,
167 token,
168 expires_at: b.expires_at,
169 created_at: now,
170 has_password: pw_hash.is_some(),
171 settings: b.settings,
172 }))
173}
174
175pub async fn delete(
176 State(s): State<AppState>,
177 user: User,
178 Path(id): Path<i64>,
179) -> Result<Json<()>> {
180 let n = s.db().execute(
181 "DELETE FROM shares WHERE id = ?1 AND owner_id = ?2 AND viewer_id IS NULL",
182 [id, user.id],
183 )?;
184 if n == 0 {
185 return Err(Error::NotFound);
186 }
187 Ok(Json(()))
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use api::Trail;
194
195 #[test]
196 fn password_links_need_the_key() {
197 let db = crate::test_db();
198 db.execute_batch(
199 "INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0);
200 INSERT INTO devices (id, user_id, name, token_hash, created_at) VALUES (1, 1, 'p', x'01', 0);
201 INSERT INTO points (device_id, ts, lat, lon) VALUES (1, 100, 0, 0), (1, 200, 0, 0);
202 INSERT INTO shares (owner_id, created_at, token, pw_hash, trail_since) VALUES (1, 0, 'open', NULL, 150), (1, 0, 'locked', 'h', NULL);
203 INSERT INTO shares (owner_id, created_at, token, expires_at) VALUES (1, 0, 'old', 1);",
204 )
205 .unwrap();
206 let auth = |token: &str, key: Option<String>| GuestAuth {
207 token: token.into(),
208 key,
209 };
210 let g = open(&db, &auth("open", None)).unwrap();
211 assert_eq!(g.access.trail, Trail::Since(150));
212 let points = crate::routes::track_points(&db, &g.access, 1, 0, 300).unwrap();
213 assert_eq!(points.iter().map(|p| p.ts).collect::<Vec<_>>(), [200]);
214 assert!(matches!(
215 open(&db, &auth("locked", None)),
216 Err(Error::Unauthorized)
217 ));
218 assert!(matches!(
219 open(&db, &auth("locked", Some("x".into()))),
220 Err(Error::Unauthorized)
221 ));
222 assert!(open(&db, &auth("locked", Some(key("locked", "h")))).is_ok());
223 assert!(matches!(
224 open(&db, &auth("old", None)),
225 Err(Error::NotFound)
226 ));
227 assert!(matches!(
228 open(&db, &auth("nope", None)),
229 Err(Error::NotFound)
230 ));
231 }
232}
233