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