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