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 | const MAX_ANONYMOUS: usize = 1000; |
| 28 | |
| 29 | pub 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)] |
| 48 | pub struct Ceremonies(Mutex<HashMap<String, (Pending, Instant)>>); |
| 49 | |
| 50 | impl 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 | |
| 73 | pub 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. |
| 78 | fn failed(e: WebauthnError) -> Error { |
| 79 | eprintln!("webauthn ceremony failed: {e:?}"); |
| 80 | Error::BadRequest("that passkey could not be used".into()) |
| 81 | } |
| 82 | |
| 83 | fn 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. |
| 96 | pub struct Rp(Webauthn); |
| 97 | |
| 98 | impl 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 | |
| 106 | pub 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. |
| 140 | pub 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 | |
| 157 | pub 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. |
| 166 | fn 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 | |
| 180 | pub 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. |
| 193 | pub 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 | |
| 226 | pub 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 | |
| 237 | pub 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 | |
| 298 | pub 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 | |
| 309 | fn too_many() -> Error { |
| 310 | Error::BadRequest(format!("you can have at most {PASSKEY_LIMIT} passkeys")) |
| 311 | } |
| 312 | |
| 313 | pub 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 | |
| 351 | pub 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, ®) |
| 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 | |
| 405 | pub 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 |