From 35d79e159a52f2ccb997583e598b6c7f6163e824 Mon Sep 17 00:00:00 2001 From: Xander Bil Date: Sun, 26 Jan 2025 21:54:21 +0100 Subject: [PATCH] add passkeys to zauth --- Cargo.lock | 254 +++++++++++++- Cargo.toml | 4 + flake.lock | 21 +- .../down.sql | 2 + .../2025-01-23-180624_create_passkeys/up.sql | 12 + src/controllers/mod.rs | 1 + src/controllers/webauthn_controller.rs | 310 ++++++++++++++++++ src/errors.rs | 13 + src/lib.rs | 11 + src/models/mod.rs | 1 + src/models/passkey.rs | 172 ++++++++++ src/webauthn.rs | 84 +++++ static/js/webauthn.js | 103 ++++++ static/scss/application.scss | 2 +- templates/layout.html | 7 + templates/passkeys/index.html | 58 ++++ templates/passkeys/new_passkey.html | 36 ++ templates/session/login.html | 5 +- templates/users/show.html | 4 +- tests/common/mod.rs | 2 +- tests/passkeys.rs | 46 +++ 21 files changed, 1131 insertions(+), 17 deletions(-) create mode 100644 migrations/2025-01-23-180624_create_passkeys/down.sql create mode 100644 migrations/2025-01-23-180624_create_passkeys/up.sql create mode 100644 src/controllers/webauthn_controller.rs create mode 100644 src/models/passkey.rs create mode 100644 src/webauthn.rs create mode 100644 static/js/webauthn.js create mode 100644 templates/passkeys/index.html create mode 100644 templates/passkeys/new_passkey.html create mode 100644 tests/passkeys.rs diff --git a/Cargo.lock b/Cargo.lock index a9e605a5..b436d5b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -125,6 +125,45 @@ dependencies = [ "rocket", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -212,6 +251,17 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64urlsafedata" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f0ad38ce7fbed55985ad5b2197f05cff8324ee6eb6638304e78f0108fae56c" +dependencies = [ + "base64 0.21.7", + "paste", + "serde", +] + [[package]] name = "basic-toml" version = "0.1.9" @@ -351,6 +401,23 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "compact_jwt" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bbab6445446e8d0b07468a01d0bfdae15879de5c440c5e47ae4ae0e18a1fba" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "hex", + "openssl", + "serde", + "serde_json", + "tracing", + "url", + "uuid", +] + [[package]] name = "cookie" version = "0.18.1" @@ -458,6 +525,26 @@ dependencies = [ "syn 2.0.66", ] +[[package]] +name = "data-encoding" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e60eed09d8c01d3cee5b7d30acb059b76614c918fa0f992e0dd6eeb10daad6f" + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.3.11" @@ -580,6 +667,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "dsl_auto_type" version = "0.1.0" @@ -842,6 +940,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + [[package]] name = "hashbrown" version = "0.14.5" @@ -866,6 +970,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -1415,6 +1525,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -1500,6 +1619,12 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pear" version = "0.2.9" @@ -1908,6 +2033,15 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.34" @@ -1995,6 +2129,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor_2" +version = "0.12.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46d75f449e01f1eddbe9b00f432d616fbbd899b809c837d0fbc380496a0dd55" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.203" @@ -2210,6 +2354,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "tempfile" version = "3.10.1" @@ -2555,6 +2710,7 @@ dependencies = [ "form_urlencoded", "idna 0.5.0", "percent-encoding", + "serde", ] [[package]] @@ -2563,6 +2719,16 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "uuid" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" +dependencies = [ + "getrandom", + "serde", +] + [[package]] name = "validator" version = "0.16.1" @@ -2692,6 +2858,73 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29e77e8859ecb93b00e4a8e56ae45f8a8dd69b1539e3d32cf4cce1db9a3a0b99" +dependencies = [ + "base64urlsafedata", + "openssl", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b44347ee0d66f222043663a6aaf5ec78022b9b11c3a9ed488c21f2bd5680856" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef48f07ed8f3dfe304d6c48e85317feba0439675f31a13063b2936c9b4eaf0d" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "compact_jwt", + "der-parser", + "hex", + "nom", + "openssl", + "rand", + "rand_chacha", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e1367f70e7dc7b83afc971ce8a54d578f4fdf488ea093021180e073744a69f" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2880,6 +3113,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "yansi" version = "1.0.1" @@ -2922,4 +3172,6 @@ dependencies = [ "toml 0.5.11", "urlencoding", "validator", + "webauthn-rs", + "webauthn-rs-proto", ] diff --git a/Cargo.toml b/Cargo.toml index 549ee4c4..543670ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,10 @@ thiserror = "1.0" validator = { version = "0.16", features = [ "derive" ] } jsonwebtoken = "9.1" openssl = "0.10" +webauthn-rs = { version = "0.5.0", features = [ + "conditional-ui" +]} +webauthn-rs-proto = "0.5.1" [build-dependencies] openssl = "0.10" diff --git a/flake.lock b/flake.lock index f89047b9..48ec1926 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1717196966, - "narHash": "sha256-yZKhxVIKd2lsbOqYd5iDoUIwsRZFqE87smE2Vzf6Ck0=", + "lastModified": 1737469691, + "narHash": "sha256-nmKOgAU48S41dTPIXAq0AHZSehWUn6ZPrUKijHAMmIk=", "owner": "nixos", "repo": "nixpkgs", - "rev": "57610d2f8f0937f39dbd72251e9614b1561942d8", + "rev": "9e4d5190a9482a1fb9d18adf0bdb83c6e506eaab", "type": "github" }, "original": { @@ -43,19 +43,16 @@ }, "rust-overlay": { "inputs": { - "flake-utils": [ - "flake-utils" - ], "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1717442957, - "narHash": "sha256-w0fqHofxM2hf3pGDXCPSdH0A09v6FgHm6I38nCWA96k=", + "lastModified": 1737599167, + "narHash": "sha256-S2rHCrQWCDVp63XxL/AQbGr1g5M8Zx14C7Jooa4oM8o=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "025e1742de4fa75b3fb63818bd9726d17da6a102", + "rev": "38374302ae9edf819eac666d1f276d62c712dd06", "type": "github" }, "original": { diff --git a/migrations/2025-01-23-180624_create_passkeys/down.sql b/migrations/2025-01-23-180624_create_passkeys/down.sql new file mode 100644 index 00000000..24a15e72 --- /dev/null +++ b/migrations/2025-01-23-180624_create_passkeys/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE passkeys; diff --git a/migrations/2025-01-23-180624_create_passkeys/up.sql b/migrations/2025-01-23-180624_create_passkeys/up.sql new file mode 100644 index 00000000..3ef0378a --- /dev/null +++ b/migrations/2025-01-23-180624_create_passkeys/up.sql @@ -0,0 +1,12 @@ +-- Your SQL goes here +CREATE TABLE passkeys ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + cred VARCHAR NOT NULL, + cred_id VARCHAR NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), + last_used TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX ix_passkeys_cred_id ON passkeys (cred_id); diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 95aac531..5e5c3e3e 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -4,3 +4,4 @@ pub mod oauth_controller; pub mod pages_controller; pub mod sessions_controller; pub mod users_controller; +pub mod webauthn_controller; diff --git a/src/controllers/webauthn_controller.rs b/src/controllers/webauthn_controller.rs new file mode 100644 index 00000000..46a64fc5 --- /dev/null +++ b/src/controllers/webauthn_controller.rs @@ -0,0 +1,310 @@ +use chrono::{DateTime, Local}; +use rocket::http::{CookieJar, Status}; +use rocket::response::status::Custom; +use rocket::response::{Redirect, Responder}; +use rocket::{serde::json::Json, State}; +use webauthn_rs::prelude::*; +use webauthn_rs_proto::{ + AuthenticatorSelectionCriteria, ResidentKeyRequirement, + UserVerificationPolicy, +}; + +use crate::config::Config; +use crate::controllers::pages_controller::rocket_uri_macro_home_page; +use crate::ephemeral::session::{ + stored_redirect_or, SessionCookie, UserSession, +}; +use crate::errors::{ + AuthenticationError, Either, LoginError, Result, ZauthError, +}; +use crate::models::passkey::{NewPassKey, PassKey}; +use crate::models::session::Session; +use crate::models::user::User; +use crate::views::accepter::Accepter; +use crate::webauthn::WebAuthnStore; +use crate::DbConn; + +#[post("/webauthn/start_register", format = "json", data = "")] +pub async fn start_register( + session: UserSession, + webauthn_store: &State, + residential: Json, + db: DbConn, +) -> Result> { + let authenticator_criteria = AuthenticatorSelectionCriteria { + authenticator_attachment: None, + resident_key: if *residential { + Some(ResidentKeyRequirement::Required) + } else { + Some(ResidentKeyRequirement::Discouraged) + }, + require_resident_key: *residential, + user_verification: UserVerificationPolicy::Required, + }; + + let exclude = PassKey::find_credentials(session.user.id, &db) + .await? + .iter() + .map(|cred| cred.cred_id().clone()) + .collect(); + + match webauthn_store.webauthn.start_passkey_registration( + Uuid::from_u128(session.user.id as u128), + &session.user.username, + &session.user.username, + Some(exclude), + ) { + Ok((mut ccr, reg_state)) => { + webauthn_store + .add_registration(session.user.id, reg_state) + .await; + + ccr.public_key.authenticator_selection = + Some(authenticator_criteria); + Ok(Json(ccr)) + }, + Err(e) => Err(e.into()), + } +} + +#[derive(Deserialize)] +pub struct PassKeyRegistration { + credential: RegisterPublicKeyCredential, + name: String, +} + +#[post("/webauthn/finish_register", format = "json", data = "")] +pub async fn finish_register<'r>( + session: UserSession, + webauthn_store: &State, + reg: Json, + db: DbConn, +) -> Result>> { + let reg_state = + match webauthn_store.fetch_registration(session.user.id).await { + Some(registration) => registration, + None => { + return Err(ZauthError::WebauthnError( + WebauthnError::ChallengeNotFound, + )) + }, + }; + + match webauthn_store + .webauthn + .finish_passkey_registration(®.credential, ®_state) + { + Ok(pk) => { + let passkey = NewPassKey { + user_id: session.user.id, + name: reg.name.clone(), + cred: pk, + }; + + PassKey::create(passkey, &db).await?; + Ok(Either::Left(Redirect::to(uri!(list_passkeys)))) + }, + Err(e) => Ok(Either::Right(template! { + "passkeys/new_passkey.html"; + current_user: User = session.user, + errors: Option = Some(e.to_string()), + })), + } +} + +#[post("/webauthn/start_auth", format = "json", data = "")] +pub async fn start_authentication( + webauthn_store: &State, + username: Json>, + db: DbConn, +) -> Result, RequestChallengeResponse)>> { + let now = Local::now(); + + let user_opt = if let Some(name) = username.into_inner() { + User::find_by_username(name, &db).await.ok() + } else { + None + }; + + match user_opt { + Some(user) => { + let creds: Vec = + PassKey::find_credentials(user.id, &db).await?; + + match webauthn_store + .webauthn + .start_passkey_authentication(creds.as_slice()) + { + Ok((rcr, auth_state)) => { + webauthn_store + .add_authentication(now, Either::Right(auth_state)) + .await; + Ok(Json((now, rcr))) + }, + Err(e) => Err(e.into()), + } + }, + None => { + match webauthn_store.webauthn.start_discoverable_authentication() { + Ok((rcr, auth_state)) => { + webauthn_store + .add_authentication(now, Either::Left(auth_state)) + .await; + Ok(Json((now, rcr))) + }, + Err(e) => Err(e.into()), + } + }, + } +} + +#[derive(Deserialize, Debug)] +pub struct PassKeyAuthentication { + id: DateTime, + username: Option, + credential: Option, +} + +async fn authenticate( + webauthn_store: &WebAuthnStore, + auth: PassKeyAuthentication, + db: &DbConn, +) -> Result { + let (result, user) = match webauthn_store + .fetch_authentication(auth.id) + .await + { + Some(Either::Left(discoverable_state)) => { + let credential = auth.credential.ok_or(ZauthError::LoginError( + LoginError::PasskeyDiscoverableError, + ))?; + let (uuid, _) = webauthn_store + .webauthn + .identify_discoverable_authentication(&credential)?; + + let user = User::find(uuid.as_u128() as i32, db).await?; + + let creds: Vec = + PassKey::find_credentials(user.id, db) + .await? + .iter() + .map(DiscoverableKey::from) + .collect(); + webauthn_store + .webauthn + .finish_discoverable_authentication( + &credential, + discoverable_state, + creds.as_slice(), + ) + .map_err(|_| { + ZauthError::LoginError(LoginError::PasskeyDiscoverableError) + }) + .map(|result| (result, user)) + }, + Some(Either::Right(state)) => { + let credential = auth + .credential + .ok_or(ZauthError::LoginError(LoginError::PasskeyError))?; + let username = auth.username.clone().ok_or( + ZauthError::Unprocessable("username is missing".to_string()), + )?; + let user = User::find_by_username(username, db).await?; + webauthn_store + .webauthn + .finish_passkey_authentication(&credential, &state) + .map_err(|_| ZauthError::LoginError(LoginError::PasskeyError)) + .map(|result| (result, user)) + }, + None => Err(ZauthError::LoginError(LoginError::PasskeyError)), + }?; + + let mut passkey = PassKey::find_by_cred_id(result.cred_id(), db).await?; + + passkey.set_last_used(); + + // Update the stored counter + let mut credential = passkey.credential()?; + if result.needs_update() + && credential.update_credential(&result).is_some_and(|b| b) + { + passkey.set_credential(credential)?; + } + + passkey.update(db).await?; + + Ok(user) +} + +#[post("/webauthn/finish_auth", format = "json", data = "")] +pub async fn finish_authentication<'r>( + webauthn_store: &State, + auth: Json, + cookies: &'r CookieJar<'_>, + config: &'r State, + db: DbConn, +) -> Result>> { + match authenticate(webauthn_store, auth.into_inner(), &db).await { + Ok(user) => { + let session = + Session::create(&user, config.user_session_duration(), &db) + .await?; + SessionCookie::new(session).login(cookies); + user.update_last_login(&db).await?; + Ok(Either::Left(stored_redirect_or(cookies, uri!(home_page)))) + }, + Err(ZauthError::LoginError(login_error)) => { + Ok(Either::Right(template! { + "session/login.html"; + error: Option = Some(login_error.to_string()), + })) + }, + Err(e) => Err(e), + } +} + +#[get("/passkeys")] +pub async fn list_passkeys<'r>( + db: DbConn, + session: UserSession, +) -> Result> { + let passkeys = PassKey::find_by_user_id(session.user.id, &db).await?; + Ok(Accepter { + html: template! { + "passkeys/index.html"; + passkeys: Vec = passkeys.clone(), + current_user: User = session.user + }, + json: Json(passkeys), + }) +} + +#[get("/passkeys/new")] +pub async fn new_passkey<'r>( + session: UserSession, +) -> Result> { + Ok(template! { "passkeys/new_passkey.html"; + current_user: User = session.user, + errors: Option = None, + }) +} + +#[delete("/passkeys/")] +pub async fn delete_passkey<'r>( + id: i32, + session: UserSession, + db: DbConn, +) -> Result> { + let passkey = PassKey::find(id, &db).await?; + if session.user.id == passkey.user_id { + passkey.delete(&db).await?; + Ok(Accepter { + html: Redirect::to(uri!(list_passkeys)), + json: Custom(Status::NoContent, ()), + }) + } else { + Err(ZauthError::AuthError(AuthenticationError::Unauthorized( + String::from("passkey is owned by another user"), + ))) + } +} diff --git a/src/errors.rs b/src/errors.rs index a7759371..669274e2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -10,6 +10,7 @@ use rocket::serde::json::Json; use rocket::tokio::sync::mpsc::error::{SendError, TrySendError}; use std::convert::Infallible; use validator::ValidationErrors; +use webauthn_rs::prelude::WebauthnError; use crate::views::accepter::Accepter; @@ -31,6 +32,8 @@ pub enum ZauthError { AuthError(#[from] AuthenticationError), #[error("Login error {0:?}")] LoginError(#[from] LoginError), + #[error("Webauthn error: {0:?}")] + WebauthnError(#[from] WebauthnError), #[error("Infallible")] Infallible(#[from] Infallible), } @@ -216,7 +219,10 @@ pub enum InternalError { Base64DecodeError(#[from] base64::DecodeError), #[error("JWT error")] JWTError(#[from] jsonwebtoken::errors::Error), + #[error("Serde error")] + SerdeError(#[from] serde_json::Error), } + pub type InternalResult = std::result::Result; #[derive(Error, Debug)] @@ -229,6 +235,13 @@ pub enum LoginError { AccountPendingMailConfirmationError, #[error("Account disabled")] AccountDisabledError, + #[error("Passkey authentication failed")] + PasskeyError, + #[error( + "Passkey authentication failed, if registered key is non-resident \ + make sure to supply a username" + )] + PasskeyDiscoverableError, } #[derive(Error, Debug)] diff --git a/src/lib.rs b/src/lib.rs index a6f1140c..7370787d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ pub mod mailer; pub mod models; pub mod token_store; pub mod util; +pub mod webauthn; use diesel_migrations::MigrationHarness; use jwt::JWTBuilder; @@ -49,6 +50,7 @@ use rocket::{Build, Rocket}; use rocket_sync_db_pools::database; use rocket_sync_db_pools::diesel::PgConnection; use simple_logger::SimpleLogger; +use webauthn::WebAuthnStore; use crate::config::{AdminEmail, Config}; use crate::controllers::*; @@ -93,6 +95,7 @@ fn assemble(rocket: Rocket) -> Rocket { let token_store = TokenStore::::new(&config); let mailer = Mailer::new(&config).unwrap(); let jwt_builder = JWTBuilder::new(&config).expect("config"); + let webauthn = WebAuthnStore::new(&config); let rocket = rocket .mount( @@ -106,6 +109,13 @@ fn assemble(rocket: Rocket) -> Rocket { clients_controller::delete_client, clients_controller::get_generate_secret, clients_controller::post_generate_secret, + webauthn_controller::start_register, + webauthn_controller::finish_register, + webauthn_controller::start_authentication, + webauthn_controller::finish_authentication, + webauthn_controller::list_passkeys, + webauthn_controller::new_passkey, + webauthn_controller::delete_passkey, oauth_controller::authorize, oauth_controller::do_authorize, oauth_controller::grant_get, @@ -158,6 +168,7 @@ fn assemble(rocket: Rocket) -> Rocket { .manage(mailer) .manage(admin_email) .manage(jwt_builder) + .manage(webauthn) .attach(DbConn::fairing()) .attach(AdHoc::config::()) .attach(AdHoc::on_ignite("Database preparation", prepare_database)); diff --git a/src/models/mod.rs b/src/models/mod.rs index 4c2a95d3..2f6533a6 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod client; pub mod mail; +pub mod passkey; pub mod session; pub mod user; diff --git a/src/models/passkey.rs b/src/models/passkey.rs new file mode 100644 index 00000000..b9ca1764 --- /dev/null +++ b/src/models/passkey.rs @@ -0,0 +1,172 @@ +use chrono::{NaiveDateTime, Utc}; +use diesel::{query_dsl::methods::FilterDsl, ExpressionMethods, RunQueryDsl}; +use validator::Validate; +use webauthn_rs::prelude::{CredentialID, Passkey}; + +use crate::{ + errors::{self, InternalError, Result, ZauthError}, + DbConn, +}; + +use self::schema::passkeys; + +pub mod schema { + table! { + use diesel::sql_types::*; + + passkeys { + id -> Integer, + user_id -> Integer, + name -> VarChar, + cred -> VarChar, + cred_id -> VarChar, + last_used -> Timestamp, + created_at -> Timestamp, + } + + } +} + +#[derive( + Queryable, Selectable, PartialEq, Debug, Clone, Serialize, AsChangeset, +)] +#[diesel(table_name = passkeys)] +pub struct PassKey { + pub id: i32, + pub user_id: i32, + pub name: String, + #[serde(skip)] + cred: String, + #[serde(skip)] + cred_id: String, + pub last_used: NaiveDateTime, + pub created_at: NaiveDateTime, +} + +#[derive(Clone, Validate)] +pub struct NewPassKey { + pub user_id: i32, + #[validate(length(min = 1, max = 254))] + pub name: String, + pub cred: Passkey, +} + +#[derive(Clone, Insertable)] +#[diesel(table_name = passkeys)] +struct NewPassKeySerialized { + user_id: i32, + name: String, + cred: String, + cred_id: String, + last_used: NaiveDateTime, +} + +impl PassKey { + pub async fn create( + passkey: NewPassKey, + db: &DbConn, + ) -> errors::Result { + passkey.validate()?; + let serialized = NewPassKeySerialized { + user_id: passkey.user_id, + name: passkey.name, + cred: serde_json::to_string(&passkey.cred) + .map_err(InternalError::from)?, + cred_id: serde_json::to_string(&passkey.cred.cred_id()) + .map_err(InternalError::from)?, + last_used: Utc::now().naive_utc(), + }; + + db.run(move |conn| { + diesel::insert_into(passkeys::table) + .values(&serialized) + .get_result::(conn) + }) + .await + .map_err(ZauthError::from) + } + + pub async fn find(id: i32, db: &DbConn) -> errors::Result { + db.run(move |conn| { + diesel::QueryDsl::find(passkeys::table, id).first(conn) + }) + .await + .map_err(ZauthError::from) + } + + pub async fn find_by_cred_id( + cred_id: &CredentialID, + db: &DbConn, + ) -> Result { + let cred_id = + serde_json::to_string(cred_id).map_err(InternalError::from)?; + db.run(move |conn| { + passkeys::table + .filter(passkeys::cred_id.eq(cred_id)) + .first(conn) + }) + .await + .map_err(ZauthError::from) + } + + pub fn credential(&self) -> Result { + Ok(serde_json::from_str::(&self.cred) + .map_err(InternalError::from)?) + } + + pub fn set_credential(&mut self, cred: Passkey) -> Result<()> { + self.cred = + serde_json::to_string(&cred).map_err(InternalError::from)?; + Ok(()) + } + + pub async fn find_credentials( + user_id: i32, + db: &DbConn, + ) -> errors::Result> { + let keys = PassKey::find_by_user_id(user_id, db); + Ok(keys + .await? + .iter() + .filter_map(|key| key.credential().ok()) + .collect()) + } + + pub async fn find_by_user_id( + user_id: i32, + db: &DbConn, + ) -> errors::Result> { + db.run(move |conn| { + passkeys::table + .filter(passkeys::user_id.eq(user_id)) + .get_results::(conn) + }) + .await + .map_err(ZauthError::from) + } + + pub fn set_last_used(&mut self) { + self.last_used = Utc::now().naive_utc(); + } + + pub async fn update(self, db: &DbConn) -> Result { + let id = self.id; + db.run(move |conn| { + diesel::update(diesel::QueryDsl::find(passkeys::table, id)) + .set(self) + .get_result(conn) + }) + .await + .map_err(ZauthError::from) + } + + pub async fn delete(self, db: &DbConn) -> Result<()> { + db.run(move |conn| { + diesel::delete(passkeys::table.filter(passkeys::id.eq(self.id))) + .execute(conn) + }) + .await + .map_err(ZauthError::from)?; + Ok(()) + } +} diff --git a/src/webauthn.rs b/src/webauthn.rs new file mode 100644 index 00000000..df64fd0b --- /dev/null +++ b/src/webauthn.rs @@ -0,0 +1,84 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Local, TimeDelta}; +use rocket::tokio::sync::Mutex; +use webauthn_rs::{ + prelude::{ + DiscoverableAuthentication, PasskeyAuthentication, PasskeyRegistration, + Url, + }, + Webauthn, WebauthnBuilder, +}; + +use crate::{config::Config, errors::Either}; + +type Authentication = Either; + +pub struct WebAuthnStore { + registrations: Mutex>, + authentications: Mutex, Authentication>>, + pub webauthn: Webauthn, +} + +impl WebAuthnStore { + pub fn new(config: &Config) -> Self { + let base_url = Url::parse(&config.base_url).expect("Invalid base url"); + let webauthn_builder = WebauthnBuilder::new( + base_url.domain().expect("No domain in base_url"), + &base_url, + ) + .expect("Invalid webauthn configuration"); + let webauthn = webauthn_builder + .build() + .expect("Invalid webauthn configuration"); + + WebAuthnStore { + registrations: Mutex::new(HashMap::new()), + authentications: Mutex::new(HashMap::new()), + webauthn, + } + } + + pub async fn add_registration( + &self, + user_id: i32, + reg_state: PasskeyRegistration, + ) { + let registrations = &mut self.registrations.lock().await; + registrations.insert(user_id, reg_state); + } + + pub async fn fetch_registration( + &self, + user_id: i32, + ) -> Option { + let registrations = &mut self.registrations.lock().await; + registrations.remove(&user_id) + } + + fn remove_expired_auths( + auths: &mut HashMap, Authentication>, + ) { + let expiration = Local::now() - TimeDelta::minutes(2); + auths.retain(|key, _auth| expiration < *key); + } + + pub async fn add_authentication( + &self, + user_id: DateTime, + auth_state: Authentication, + ) { + let mut auths = self.authentications.lock().await; + Self::remove_expired_auths(&mut auths); + auths.insert(user_id, auth_state); + } + + pub async fn fetch_authentication( + &self, + user_id: DateTime, + ) -> Option> { + let mut auths = self.authentications.lock().await; + Self::remove_expired_auths(&mut auths); + auths.remove(&user_id) + } +} diff --git a/static/js/webauthn.js b/static/js/webauthn.js new file mode 100644 index 00000000..a7bece25 --- /dev/null +++ b/static/js/webauthn.js @@ -0,0 +1,103 @@ +function register_passkey() { + const name = document.getElementById("passkey-name").value; + const resident = document.getElementById("passkey-resident").checked; + fetch('/webauthn/start_register', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(resident) + }) + .then(response => response.json() ) + .then(credentialCreationOptions => { + credentialCreationOptions.publicKey.challenge = Base64.toUint8Array(credentialCreationOptions.publicKey.challenge); + credentialCreationOptions.publicKey.user.id = Base64.toUint8Array(credentialCreationOptions.publicKey.user.id); + credentialCreationOptions.publicKey.excludeCredentials?.forEach(function (listItem) { + listItem.id = Base64.toUint8Array(listItem.id) + }); + + return navigator.credentials.create({ + publicKey: credentialCreationOptions.publicKey + }); + }) + .then((credential) => { + fetch('/webauthn/finish_register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: name, + credential: { + id: credential.id, + rawId: Base64.fromUint8Array(new Uint8Array(credential.rawId), true), + type: credential.type, + response: { + attestationObject: Base64.fromUint8Array(new Uint8Array(credential.response.attestationObject), true), + clientDataJSON: Base64.fromUint8Array(new Uint8Array(credential.response.clientDataJSON), true), + }, + } + }) + }) + .then(finish); + }) +} + +function login_passkey() { + const username = document.getElementById("login-username").value; + let id = null; + fetch('/webauthn/start_auth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify((username.length > 0) ? username : null) + }) + .then(response => response.json()) + .then(([id,credentialRequestOptions]) => { + credentialRequestOptions.publicKey.challenge = Base64.toUint8Array(credentialRequestOptions.publicKey.challenge); + credentialRequestOptions.publicKey.allowCredentials?.forEach(function (listItem) { + listItem.id = Base64.toUint8Array(listItem.id) + }); + + this.id = id + return navigator.credentials.get({ + publicKey: credentialRequestOptions.publicKey, + }); + }) + .then((assertion) => { + return { + id: assertion.id, + rawId: Base64.fromUint8Array(new Uint8Array(assertion.rawId), true), + type: assertion.type, + response: { + authenticatorData: Base64.fromUint8Array(new Uint8Array(assertion.response.authenticatorData), true), + clientDataJSON: Base64.fromUint8Array(new Uint8Array(assertion.response.clientDataJSON), true), + signature: Base64.fromUint8Array(new Uint8Array(assertion.response.signature), true), + userHandle: Base64.fromUint8Array(new Uint8Array(assertion.response.userHandle), true) + } + } + } + , (error) => null) + .then((credential) => { + fetch('/webauthn/finish_auth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: this.id, + username: ((username.length > 0) ? username : null), + credential: credential, + }), + }) + .then(finish); + }) +} + +function finish(response) { + const contentType = response.headers.get('Content-Type'); + if (response.ok && response.redirected){ + window.location.href = response.url; + } else if (contentType && contentType.includes('text/html')){ + response.text().then((html) => document.documentElement.innerHTML = html); + } +} diff --git a/static/scss/application.scss b/static/scss/application.scss index 5158171e..6a2575e9 100644 --- a/static/scss/application.scss +++ b/static/scss/application.scss @@ -3,7 +3,7 @@ $primary: #ff7f00; $text: #172940; $link: $primary; $title-color: $text; -// $subtitle-color: $grey-light; +//$subtitle-color: $grey-light; $family-sans-serif: "Inter",sans-serif; diff --git a/templates/layout.html b/templates/layout.html index fdd0120b..adc76beb 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -13,6 +13,13 @@ + + + {% block head %} {% endblock head %} diff --git a/templates/passkeys/index.html b/templates/passkeys/index.html new file mode 100644 index 00000000..b1f95903 --- /dev/null +++ b/templates/passkeys/index.html @@ -0,0 +1,58 @@ +{% extends "base_logged_in.html" %} + + +{% block content %} + + +
+ + + +
+
+
Passkeys ({{ passkeys.len() }})
+
+ + +
+ + + + + + + + + + + + + + {% for passkey in passkeys %} + + + + + + + + + + + + + + + {% endfor %} + +
NameLast UsedCreated atDelete
{{ passkey.name }}{{ passkey.last_used.format("%d/%m/%y").to_string() }}{{ passkey.created_at.format("%d/%m/%y").to_string() }} +
+ + +
+
+
+{% endblock content %} + diff --git a/templates/passkeys/new_passkey.html b/templates/passkeys/new_passkey.html new file mode 100644 index 00000000..9634acca --- /dev/null +++ b/templates/passkeys/new_passkey.html @@ -0,0 +1,36 @@ +{% extends "base_logged_in.html" %} + + +{% block content %} +
+ +
+ Register a new passkey +
+ + + {% match errors %} + {% when Some with (errors) %} +
+ {{ errors }} +
+ {% when None %} + {% endmatch %} + +
+ + +
+ +
+ +

+ A resident key allows for a discoverable credential, so giving a username is not required to log in.
+ It however takes up extra storage, and physical security keys have therefore often a limit on the number of resident keys they can store. +

+ +
+ + +
+{% endblock content %} diff --git a/templates/session/login.html b/templates/session/login.html index 444bcb9a..56c69ed4 100644 --- a/templates/session/login.html +++ b/templates/session/login.html @@ -29,7 +29,7 @@
- +
@@ -43,6 +43,9 @@ + + +
Don't have an account? Create one here! diff --git a/templates/users/show.html b/templates/users/show.html index 3ec9e0c3..95bb3aba 100644 --- a/templates/users/show.html +++ b/templates/users/show.html @@ -77,9 +77,11 @@
- {% if current_user.admin %} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index a5e495f9..ee6d58b3 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -58,7 +58,7 @@ pub fn config() -> Config { async fn reset_db(db: &DbConn) { db.run(|conn| { - sql_query("TRUNCATE TABLE mails, sessions, users, clients") + sql_query("TRUNCATE TABLE mails, sessions, users, clients, passkeys") .execute(conn) .expect("drop all tables"); }) diff --git a/tests/passkeys.rs b/tests/passkeys.rs new file mode 100644 index 00000000..94273151 --- /dev/null +++ b/tests/passkeys.rs @@ -0,0 +1,46 @@ +#![feature(async_closure)] + +extern crate diesel; +extern crate rocket; + +use common::HttpClient; +use rocket::http::ContentType; +use rocket::http::Status; +use zauth::models::user::User; + +mod common; + +#[rocket::async_test] +async fn register_passkey_as_visitor() { + common::as_visitor(async move |http_client: HttpClient, _db| { + let response = http_client + .post("/webauthn/start_register") + .header(ContentType::JSON) + .body("true") + .dispatch() + .await; + + assert_eq!(response.status(), Status::Unauthorized); + }) + .await; +} + +#[rocket::async_test] +async fn list_passkeys_as_visitor() { + common::as_visitor(async move |http_client: HttpClient, _db| { + let response = http_client.get("/passkeys").dispatch().await; + + assert_eq!(response.status(), Status::Unauthorized); + }) + .await; +} + +#[rocket::async_test] +async fn list_passkeys_as_user() { + common::as_user(async move |http_client: HttpClient, _db, _user: User| { + let response = http_client.get("/passkeys").dispatch().await; + + assert_eq!(response.status(), Status::Ok); + }) + .await; +}