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