passkeys.rs
⎇
Raw
1//! Passkeys (WebAuthn): registration, sign-in, and the second factor after a password.
2//!
3//! Sign-in uses discoverable credentials only, so the user types no name. That keeps the
4//! unauthenticated part free of anything that could tell whether a username exists.
5
6use std::collections::HashMap;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10use api::{Challenge, ChallengeAnswer, LoginResult};
11use axum::Json;
12use axum::extract::{FromRequestParts, Path as UrlPath, State};
13use axum::http::request::Parts;
14use axum::http::{HeaderMap, Uri, header};
15use axum::response::{IntoResponse, Response};
16use rusqlite::{Connection, OptionalExtension, params};
17use webauthn_rs::prelude::*;
18use webauthn_rs_proto::ResidentKeyRequirement;
19
20use crate::auth::{self, ClientIp, User};
21use crate::{AppState, Error, now};
22
23pub const PASSKEY_LIMIT: i64 = 10;
24/// How long a browser has to answer a challenge.
25const TTL: Duration = Duration::from_secs(300);
26/// Anyone can start a passkey sign-in, so their pending challenges need a cap.
27/// Past it the oldest goes, so a flood of starts cannot block everyone else's sign-in.
28const MAX_ANONYMOUS: usize = 1000;
29/// Passkey sign-in starts per address in 15 minutes, so one client cannot push out the others' challenges.
30const MAX_STARTS: u32 = 30;
31
32pub enum Pending {
33 Register {
34 user_id: i64,
35 state: Box<PasskeyRegistration>,
36 },
37 SignIn(Box<DiscoverableAuthentication>),
38 /// The password passed. The account also needs a passkey.
39 SecondFactor {
40 user_id: i64,
41 state: Box<PasskeyAuthentication>,
42 },
43 /// A passkey passed. The account also needs its password.
44 NeedsPassword {
45 user_id: i64,
46 },
47}
48
49/// WebAuthn takes two requests. This holds what the second one needs, keyed by a handle the client echoes.
50#[derive(Default)]
51pub struct Ceremonies(Mutex<HashMap<String, (Pending, Instant)>>);
52
53impl Ceremonies {
54 pub fn put(&self, pending: Pending) -> String {
55 let mut map = self.0.lock().unwrap();
56 map.retain(|_, (_, at)| at.elapsed() < TTL);
57 let anonymous = |p: &Pending| matches!(p, Pending::SignIn(_));
58 if anonymous(&pending) {
59 let mut started: Vec<(String, Instant)> = map
60 .iter()
61 .filter(|(_, (p, _))| anonymous(p))
62 .map(|(id, (_, at))| (id.clone(), *at))
63 .collect();
64 if started.len() >= MAX_ANONYMOUS {
65 started.sort_by_key(|(_, at)| *at);
66 for (id, _) in &started[..=started.len() - MAX_ANONYMOUS] {
67 map.remove(id);
68 }
69 }
70 }
71 let (id, _) = auth::new_secret();
72 map.insert(id.clone(), (pending, Instant::now()));
73 id
74 }
75
76 /// One handle answers one challenge.
77 pub fn take(&self, id: &str) -> Option<Pending> {
78 let mut map = self.0.lock().unwrap();
79 map.retain(|_, (_, at)| at.elapsed() < TTL);
80 map.remove(id).map(|(p, _)| p)
81 }
82}
83
84pub fn expired() -> Error {
85 Error::BadRequest("that took too long, please try again".into())
86}
87
88/// The details go to the log. To the user every failure is the same.
89fn failed(e: WebauthnError) -> Error {
90 eprintln!("webauthn ceremony failed: {e:?}");
91 Error::BadRequest("that passkey could not be used".into())
92}
93
94fn challenge<T: serde::Serialize>(
95 state: &AppState,
96 pending: Pending,
97 options: &T,
98) -> Result<Challenge, Error> {
99 let options = serde_json::to_string(options).map_err(|e| Error::Internal(e.to_string()))?;
100 Ok(Challenge {
101 state_id: state.ceremonies.put(pending),
102 options,
103 })
104}
105
106/// The relying party for the address the browser is on.
107pub struct Rp(Webauthn);
108
109impl FromRequestParts<AppState> for Rp {
110 type Rejection = Error;
111
112 async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Error> {
113 relying_party(state, &parts.uri, &parts.headers).map(Rp)
114 }
115}
116
117pub fn relying_party(state: &AppState, uri: &Uri, headers: &HeaderMap) -> Result<Webauthn, Error> {
118 let origin = match &state.public_url {
119 Some(url) => url.clone(),
120 None => {
121 // HTTP/2 carries the host in the URI, HTTP/1.1 in the Host header.
122 let host = match uri.authority() {
123 Some(a) => a.as_str().to_owned(),
124 None => headers
125 .get(header::HOST)
126 .and_then(|v| v.to_str().ok())
127 .ok_or_else(|| Error::BadRequest("no Host header".into()))?
128 .to_owned(),
129 };
130 Url::parse(&format!("http://{host}")).map_err(|e| Error::BadRequest(e.to_string()))?
131 }
132 };
133 // The browser signs its own origin. A mismatch would only fail later, with no useful message.
134 let expected = origin.origin().ascii_serialization();
135 if let Some(browser) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok())
136 && browser != expected
137 {
138 return Err(Error::BadRequest(format!(
139 "passkeys are set up for {expected}, but this page is {browser}. Set --public-url to the address you use."
140 )));
141 }
142 let rp_id = origin.domain().ok_or_else(|| {
143 Error::BadRequest("passkeys need a domain name, not an IP address".into())
144 })?;
145 WebauthnBuilder::new(rp_id, &origin)
146 .and_then(|b| b.rp_name("opentracker").build())
147 .map_err(failed)
148}
149
150/// The stored passkeys of a user. An unreadable row is skipped, so it cannot lock the user out of the others.
151pub fn load(db: &Connection, user_id: i64) -> Result<Vec<(i64, Passkey)>, Error> {
152 let rows: Vec<(i64, String)> = db
153 .prepare_cached("SELECT id, passkey FROM passkeys WHERE user_id = ?1")?
154 .query_map([user_id], |r| Ok((r.get(0)?, r.get(1)?)))?
155 .collect::<rusqlite::Result<_>>()?;
156 Ok(rows
157 .into_iter()
158 .filter_map(|(id, json)| match serde_json::from_str(&json) {
159 Ok(key) => Some((id, key)),
160 Err(e) => {
161 eprintln!("passkey {id} is unreadable: {e}");
162 None
163 }
164 })
165 .collect())
166}
167
168pub fn count(db: &Connection, user_id: i64) -> Result<i64, Error> {
169 Ok(db.query_row(
170 "SELECT COUNT(*) FROM passkeys WHERE user_id = ?1",
171 [user_id],
172 |r| r.get(0),
173 )?)
174}
175
176/// Stores the new signature counter and the time of use.
177fn record_use(db: &Connection, user_id: i64, result: &AuthenticationResult) -> Result<(), Error> {
178 for (id, mut key) in load(db, user_id)? {
179 if key.cred_id() == result.cred_id() {
180 key.update_credential(result);
181 let json = serde_json::to_string(&key).map_err(|e| Error::Internal(e.to_string()))?;
182 db.execute(
183 "UPDATE passkeys SET passkey = ?1, last_used_at = ?2 WHERE id = ?3",
184 params![json, now(), id],
185 )?;
186 }
187 }
188 Ok(())
189}
190
191pub fn sign_in(state: &AppState, user_id: i64) -> Result<Response, Error> {
192 let cookie = auth::create_session(state, user_id)?;
193 Ok((
194 [(header::SET_COOKIE, cookie)],
195 Json(LoginResult {
196 ok: true,
197 ..Default::default()
198 }),
199 )
200 .into_response())
201}
202
203/// The password passed. Asks for one of the user's passkeys next.
204pub fn second_factor(
205 state: &AppState,
206 uri: &Uri,
207 headers: &HeaderMap,
208 user_id: i64,
209) -> Result<Response, Error> {
210 let rp = relying_party(state, uri, headers)?;
211 let keys: Vec<Passkey> = load(&state.db(), user_id)?
212 .into_iter()
213 .map(|(_, k)| k)
214 .collect();
215 if keys.is_empty() {
216 return Err(Error::Internal(format!(
217 "user {user_id} needs a passkey but has none"
218 )));
219 }
220 let (options, auth_state) = rp.start_passkey_authentication(&keys).map_err(failed)?;
221 let ch = challenge(
222 state,
223 Pending::SecondFactor {
224 user_id,
225 state: Box::new(auth_state),
226 },
227 &options,
228 )?;
229 Ok(Json(LoginResult {
230 ok: false,
231 passkey_challenge: Some(ch),
232 ..Default::default()
233 })
234 .into_response())
235}
236
237pub async fn login_begin(
238 State(s): State<AppState>,
239 ClientIp(ip): ClientIp,
240 Rp(rp): Rp,
241) -> Result<Json<Challenge>, Error> {
242 let key = format!("passkey {}", auth::ip_group(ip));
243 s.limiter.attempt(&[(&key, MAX_STARTS)])?;
244 let (mut options, auth_state) = rp.start_discoverable_authentication().map_err(failed)?;
245 // Without this the browser waits for the autofill dropdown instead of showing its dialog.
246 options.mediation = None;
247 Ok(Json(challenge(
248 &s,
249 Pending::SignIn(Box::new(auth_state)),
250 &options,
251 )?))
252}
253
254pub async fn login_finish(
255 State(s): State<AppState>,
256 Rp(rp): Rp,
257 Json(b): Json<ChallengeAnswer>,
258) -> Result<Response, Error> {
259 let pending = s.ceremonies.take(&b.state_id).ok_or_else(expired)?;
260 let cred: PublicKeyCredential = serde_json::from_str(&b.credential)
261 .map_err(|_| Error::BadRequest("unreadable credential".into()))?;
262 let db = s.db();
263 let (user_id, password_done) = match pending {
264 Pending::SignIn(auth_state) => {
265 // The user handle is only a claim until the signature checks out against that user's keys.
266 let (handle, _) = rp
267 .identify_discoverable_authentication(&cred)
268 .map_err(failed)?;
269 let user_id: i64 = db
270 .query_row(
271 "SELECT id FROM users WHERE webauthn_id = ?1",
272 [handle.to_string()],
273 |r| r.get(0),
274 )
275 .optional()?
276 .ok_or_else(|| failed(WebauthnError::CredentialNotFound))?;
277 let keys: Vec<DiscoverableKey> =
278 load(&db, user_id)?.iter().map(|(_, k)| k.into()).collect();
279 let result = rp
280 .finish_discoverable_authentication(&cred, *auth_state, &keys)
281 .map_err(failed)?;
282 record_use(&db, user_id, &result)?;
283 (user_id, false)
284 }
285 Pending::SecondFactor {
286 user_id,
287 state: auth_state,
288 } => {
289 let result = rp
290 .finish_passkey_authentication(&cred, &auth_state)
291 .map_err(failed)?;
292 record_use(&db, user_id, &result)?;
293 (user_id, true)
294 }
295 _ => return Err(expired()),
296 };
297 let two_factor: bool = db.query_row(
298 "SELECT two_factor FROM users WHERE id = ?1",
299 [user_id],
300 |r| r.get(0),
301 )?;
302 drop(db);
303 if two_factor && !password_done {
304 let state_id = s.ceremonies.put(Pending::NeedsPassword { user_id });
305 return Ok(Json(LoginResult {
306 ok: false,
307 password_required: Some(state_id),
308 ..Default::default()
309 })
310 .into_response());
311 }
312 sign_in(&s, user_id)
313}
314
315pub async fn list(State(s): State<AppState>, user: User) -> Result<Json<Vec<api::Passkey>>, Error> {
316 let keys = s
317 .db()
318 .prepare_cached("SELECT id, name, created_at, last_used_at FROM passkeys WHERE user_id = ?1 ORDER BY id")?
319 .query_map([user.id], |r| {
320 Ok(api::Passkey { id: r.get(0)?, name: r.get(1)?, created_at: r.get(2)?, last_used_at: r.get(3)? })
321 })?
322 .collect::<rusqlite::Result<_>>()?;
323 Ok(Json(keys))
324}
325
326fn too_many() -> Error {
327 Error::BadRequest(format!("you can have at most {PASSKEY_LIMIT} passkeys"))
328}
329
330pub async fn register_begin(
331 State(s): State<AppState>,
332 user: User,
333 Rp(rp): Rp,
334) -> Result<Json<Challenge>, Error> {
335 user.check_recent()?;
336 let db = s.db();
337 if count(&db, user.id)? >= PASSKEY_LIMIT {
338 return Err(too_many());
339 }
340 let handle: String = db.query_row(
341 "SELECT webauthn_id FROM users WHERE id = ?1",
342 [user.id],
343 |r| r.get(0),
344 )?;
345 let handle = Uuid::parse_str(&handle).map_err(|e| Error::Internal(e.to_string()))?;
346 // The authenticator then refuses a second credential for the same account.
347 let existing: Vec<CredentialID> = load(&db, user.id)?
348 .iter()
349 .map(|(_, k)| k.cred_id().clone())
350 .collect();
351 drop(db);
352 let (mut options, reg) = rp
353 .start_passkey_registration(handle, &user.username, &user.username, Some(existing))
354 .map_err(failed)?;
355 // webauthn-rs asks for a non-discoverable credential, but sign-in without a username needs a discoverable one.
356 if let Some(sel) = options.public_key.authenticator_selection.as_mut() {
357 sel.resident_key = Some(ResidentKeyRequirement::Required);
358 }
359 Ok(Json(challenge(
360 &s,
361 Pending::Register {
362 user_id: user.id,
363 state: Box::new(reg),
364 },
365 &options,
366 )?))
367}
368
369pub async fn register_finish(
370 State(s): State<AppState>,
371 user: User,
372 Rp(rp): Rp,
373 Json(b): Json<ChallengeAnswer>,
374) -> Result<Json<api::Passkey>, Error> {
375 let Some(Pending::Register {
376 user_id,
377 state: reg,
378 }) = s.ceremonies.take(&b.state_id)
379 else {
380 return Err(expired());
381 };
382 if user_id != user.id {
383 return Err(expired());
384 }
385 let cred: RegisterPublicKeyCredential = serde_json::from_str(&b.credential)
386 .map_err(|_| Error::BadRequest("unreadable credential".into()))?;
387 let key = rp
388 .finish_passkey_registration(&cred, &reg)
389 .map_err(failed)?;
390 let json = serde_json::to_string(&key).map_err(|e| Error::Internal(e.to_string()))?;
391 let name = match b.name.trim() {
392 "" => "Passkey",
393 n => n,
394 };
395 let name: String = name.chars().take(100).collect();
396
397 let db = s.db();
398 if count(&db, user.id)? >= PASSKEY_LIMIT {
399 return Err(too_many());
400 }
401 let created_at = now();
402 db.execute(
403 "INSERT INTO passkeys (user_id, cred_id, passkey, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
404 params![user.id, key.cred_id().as_ref(), json, name, created_at],
405 )
406 .map_err(crate::taken("that passkey is already registered"))?;
407 let id = db.last_insert_rowid();
408 drop(db);
409 auth::end_other_sessions(&s, &user)?;
410 Ok(Json(api::Passkey {
411 id,
412 name,
413 created_at,
414 last_used_at: None,
415 }))
416}
417
418pub async fn delete(
419 State(s): State<AppState>,
420 user: User,
421 UrlPath(id): UrlPath<i64>,
422) -> Result<Json<()>, Error> {
423 user.check_recent()?;
424 let db = s.db();
425 let (has_password, two_factor): (bool, bool) = db.query_row(
426 "SELECT pw_hash IS NOT NULL, two_factor FROM users WHERE id = ?1",
427 [user.id],
428 |r| Ok((r.get(0)?, r.get(1)?)),
429 )?;
430 if count(&db, user.id)? == 1 {
431 if two_factor {
432 return Err(Error::BadRequest(
433 "turn off two-factor sign-in before removing your last passkey".into(),
434 ));
435 }
436 if !has_password {
437 return Err(Error::BadRequest(
438 "set a password before removing your last passkey".into(),
439 ));
440 }
441 }
442 if db.execute(
443 "DELETE FROM passkeys WHERE id = ?1 AND user_id = ?2",
444 [id, user.id],
445 )? == 0
446 {
447 return Err(Error::NotFound);
448 }
449 drop(db);
450 auth::end_other_sessions(&s, &user)?;
451 Ok(Json(()))
452}
453