passkeys.rs
| 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 | |
| 6 | use std::collections::HashMap; |
| 7 | use std::sync::Mutex; |
| 8 | use std::time::{Duration, Instant}; |
| 9 | |
| 10 | use api::{Challenge, ChallengeAnswer, LoginResult}; |
| 11 | use axum::Json; |
| 12 | use axum::extract::{FromRequestParts, Path as UrlPath, State}; |
| 13 | use axum::http::request::Parts; |
| 14 | use axum::http::{HeaderMap, Uri, header}; |
| 15 | use axum::response::{IntoResponse, Response}; |
| 16 | use rusqlite::{Connection, OptionalExtension, params}; |
| 17 | use webauthn_rs::prelude::*; |
| 18 | use webauthn_rs_proto::ResidentKeyRequirement; |
| 19 | |
| 20 | use crate::auth::{self, User}; |
| 21 | use crate::{AppState, Error, now}; |
| 22 | |
| 23 | pub const PASSKEY_LIMIT: i64 = 10; |
| 24 | /// How long a browser has to answer a challenge. |
| 25 | const 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. |
| 28 | const MAX_ANONYMOUS: usize = 1000; |
| 29 | |
| 30 | pub 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)] |
| 49 | pub struct Ceremonies(Mutex<HashMap<String, (Pending, Instant)>>); |
| 50 | |
| 51 | impl 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 | |
| 82 | pub 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. |
| 87 | fn failed(e: WebauthnError) -> Error { |
| 88 | eprintln!("webauthn ceremony failed: {e:?}"); |
| 89 | Error::BadRequest("that passkey could not be used".into()) |
| 90 | } |
| 91 | |
| 92 | fn 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. |
| 105 | pub struct Rp(Webauthn); |
| 106 | |
| 107 | impl 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 | |
| 115 | pub 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. |
| 149 | pub 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 | |
| 166 | pub 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. |
| 175 | fn 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 | |
| 189 | pub 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. |
| 202 | pub 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 | |
| 235 | pub 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 | |
| 246 | pub 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 | |
| 307 | pub 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 | |
| 318 | fn too_many() -> Error { |
| 319 | Error::BadRequest(format!("you can have at most {PASSKEY_LIMIT} passkeys")) |
| 320 | } |
| 321 | |
| 322 | pub 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 | |
| 360 | pub 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, ®) |
| 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 | |
| 414 | pub 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 |