diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a213662..ef153d1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -59,6 +59,45 @@ dependencies = [ "password-hash", ] +[[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 1.0.69", + "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.106", + "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.106", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -627,6 +666,8 @@ dependencies = [ "reqwest", "serde", "serde-aux", + "serde_bytes", + "serde_cbor_2", "serde_json", "sha2", "tempfile", @@ -639,6 +680,8 @@ dependencies = [ "url", "utoipa", "uuid", + "webauthn-rs", + "webauthn-rs-core", ] [[package]] @@ -690,6 +733,17 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "base64urlsafedata" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "215ee31f8a88f588c349ce2d20108b2ed96089b96b9c2b03775dc35dd72938e8" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -939,6 +993,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.4.9" @@ -1006,6 +1066,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "der" version = "0.6.1" @@ -1016,6 +1082,20 @@ dependencies = [ "zeroize", ] +[[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.5.4" @@ -1255,6 +1335,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1429,6 +1524,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2159,18 +2265,65 @@ 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.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl" +version = "0.10.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "2.10.1" @@ -2231,6 +2384,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pdfium-render" version = "0.8.35" @@ -2707,6 +2866,15 @@ dependencies = [ "semver", ] +[[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 = "1.1.2" @@ -2946,6 +3114,26 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3801,6 +3989,74 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77a2892ec44032e6c48dad9aad1b05fada09c346ada11d8d32db119b4b4f205" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7c3a2f9c8bddd524e47bbd427bcf3a28aa074de55d74470b42a91a41937b8e" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f1d80f3146382529fe70a3ab5d0feb2413a015204ed7843f9377cd39357fc4" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.8.5", + "rand_chacha 0.3.1", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e786894f89facb9aaf1c5f6559670236723c98382e045521c76f3d5ca5047bd" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "1.0.3" @@ -4043,6 +4299,23 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[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 1.0.69", + "time", +] + [[package]] name = "xmlparser" version = "0.13.6" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2bc86bb..b65b5be 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -56,6 +56,9 @@ anyhow = "1.0" # Authentication & security argon2 = "0.5" jsonwebtoken = "9" +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } +serde_bytes = "0.11" +serde_cbor_2 = "0.13" # Misc rand = "0.8" @@ -64,3 +67,4 @@ rand = "0.8" once_cell = "1.19" hyper = "1.2" http-body-util = "0.1" +webauthn-rs-core = "0.5" diff --git a/backend/migrations/202512010130_add_user_passkeys/down.sql b/backend/migrations/202512010130_add_user_passkeys/down.sql new file mode 100644 index 0000000..44fd842 --- /dev/null +++ b/backend/migrations/202512010130_add_user_passkeys/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_passkeys; diff --git a/backend/migrations/202512010130_add_user_passkeys/up.sql b/backend/migrations/202512010130_add_user_passkeys/up.sql new file mode 100644 index 0000000..565a741 --- /dev/null +++ b/backend/migrations/202512010130_add_user_passkeys/up.sql @@ -0,0 +1,19 @@ +CREATE TABLE user_passkeys ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, + public_key BYTEA NOT NULL, + credential JSONB NOT NULL, + sign_count BIGINT NOT NULL, + transports TEXT[] NOT NULL DEFAULT '{}', + aaguid UUID, + nickname TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + revoked_by UUID, + revoked_reason TEXT +); + +CREATE INDEX user_passkeys_user_id_idx ON user_passkeys (user_id); diff --git a/backend/migrations/202512010140_add_webauthn_challenges/down.sql b/backend/migrations/202512010140_add_webauthn_challenges/down.sql new file mode 100644 index 0000000..be00eb1 --- /dev/null +++ b/backend/migrations/202512010140_add_webauthn_challenges/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS webauthn_challenges; diff --git a/backend/migrations/202512010140_add_webauthn_challenges/up.sql b/backend/migrations/202512010140_add_webauthn_challenges/up.sql new file mode 100644 index 0000000..97ae634 --- /dev/null +++ b/backend/migrations/202512010140_add_webauthn_challenges/up.sql @@ -0,0 +1,13 @@ +CREATE TABLE webauthn_challenges ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL, + challenge BYTEA NOT NULL, + state BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication')) +); + +CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges (user_id); +CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges (expires_at); diff --git a/backend/migrations/202512010150_drop_user_membership_role/down.sql b/backend/migrations/202512010150_drop_user_membership_role/down.sql new file mode 100644 index 0000000..404df88 --- /dev/null +++ b/backend/migrations/202512010150_drop_user_membership_role/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_memberships + ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; diff --git a/backend/migrations/202512010150_drop_user_membership_role/up.sql b/backend/migrations/202512010150_drop_user_membership_role/up.sql new file mode 100644 index 0000000..63117ff --- /dev/null +++ b/backend/migrations/202512010150_drop_user_membership_role/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_memberships + DROP COLUMN role; diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index e1bf0b9..211e91b 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -1,4 +1,5 @@ pub mod jwt; +pub mod passkeys; pub mod password; pub mod webdav_tokens; diff --git a/backend/src/auth/passkeys.rs b/backend/src/auth/passkeys.rs new file mode 100644 index 0000000..e2050d2 --- /dev/null +++ b/backend/src/auth/passkeys.rs @@ -0,0 +1,507 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use webauthn_rs::prelude::{Credential, *}; + +use crate::{ + config::AppConfig, + error::{AppError, AppResult}, + models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge}, + schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl}, +}; + +const PURPOSE_REGISTRATION: &str = "registration"; +const PURPOSE_AUTHENTICATION: &str = "authentication"; +const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10; + +#[derive(Clone)] +pub struct PasskeyService { + webauthn: Arc, + challenge_ttl: ChronoDuration, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RegistrationChallengeResponse { + pub challenge_id: Uuid, + #[serde(flatten)] + pub challenge: CreationChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationChallengeResponse { + pub challenge_id: Uuid, + #[serde(flatten)] + pub challenge: RequestChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeySummary { + pub id: Uuid, + pub nickname: Option, + pub created_at: NaiveDateTime, + pub last_used_at: Option, + pub transports: Vec, + pub revoked_at: Option, + pub revoked_reason: Option, +} + +impl PasskeyService { + pub fn try_new(config: &AppConfig) -> Result> { + let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) { + Some(rp_id) if !rp_id.is_empty() => rp_id, + _ => return Ok(None), + }; + let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) { + Some(origin) if !origin.is_empty() => origin, + _ => return Ok(None), + }; + + let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?; + + let builder = WebauthnBuilder::new(rp_id, &origin) + .context("failed to initialise WebAuthn builder")? + .rp_name(&config.webauthn_rp_name) + .allow_subdomains(false) + .allow_any_port(false); + + let webauthn = builder + .build() + .context("failed to build WebAuthn instance")?; + + Ok(Some(Self { + webauthn: Arc::new(webauthn), + challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES), + })) + } + + fn prune_expired(&self, conn: &mut PgConnection) { + let now = Utc::now().naive_utc(); + let _ = diesel::delete( + challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)), + ) + .execute(conn); + } + + pub fn start_registration( + &self, + conn: &mut PgConnection, + user: &User, + ) -> AppResult { + self.prune_expired(conn); + + let existing: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user.id)) + .filter(passkey_dsl::revoked_at.is_null()) + .load(conn)?; + + let exclude = if existing.is_empty() { + None + } else { + Some( + existing + .iter() + .map(|pk| CredentialID::from(pk.credential_id.clone())) + .collect(), + ) + }; + + let (challenge, state) = self + .webauthn + .start_passkey_registration(user.id, &user.username, &user.username, exclude) + .map_err(|err| { + tracing::error!(error = %err, "failed to start passkey registration"); + AppError::internal("failed to start passkey registration") + })?; + + let challenge_id = Uuid::new_v4(); + let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); + let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); + let state_bytes = serde_json::to_vec(&state) + .context("failed to encode passkey registration state") + .map_err(AppError::internal)?; + + let record = NewWebauthnChallenge { + id: challenge_id, + user_id: Some(user.id), + purpose: PURPOSE_REGISTRATION.to_string(), + challenge: challenge_bytes, + state: state_bytes, + expires_at, + }; + + diesel::insert_into(challenge_dsl::webauthn_challenges) + .values(&record) + .execute(conn)?; + + Ok(RegistrationChallengeResponse { + challenge_id, + challenge, + }) + } + + pub fn finish_registration( + &self, + conn: &mut PgConnection, + user: &User, + challenge_id: Uuid, + credential: RegisterPublicKeyCredential, + nickname: Option, + ) -> AppResult { + let record: WebauthnChallenge = challenge_dsl::webauthn_challenges + .find(challenge_id) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::bad_request("challenge not found") + } else { + AppError::from(err) + } + })?; + + if record.purpose != PURPOSE_REGISTRATION { + return Err(AppError::bad_request("challenge is not for registration")); + } + + if record.user_id != Some(user.id) { + return Err(AppError::unauthorized()); + } + + if record.expires_at < Utc::now().naive_utc() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::bad_request("challenge expired")); + } + + let state: PasskeyRegistration = serde_json::from_slice(&record.state) + .context("failed to decode registration state") + .map_err(AppError::internal)?; + + let passkey = self + .webauthn + .finish_passkey_registration(&credential, &state) + .map_err(|err| { + tracing::warn!(error = %err, "passkey registration validation failed"); + AppError::bad_request("invalid passkey attestation") + })?; + + let credential_struct: Credential = passkey.clone().into(); + let credential_id_vec: Vec = credential_struct.cred_id.clone().into(); + + let duplicate = passkey_dsl::user_passkeys + .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) + .first::(conn) + .optional()?; + if duplicate.is_some() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::conflict("credential already registered")); + } + + let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) + .context("failed to encode credential public key") + .map_err(AppError::internal)?; + + let transports: Vec> = credential_struct + .transports + .clone() + .unwrap_or_default() + .into_iter() + .map(|transport| Some(transport.as_ref().to_string())) + .collect(); + + let aaguid = match credential_struct.attestation.metadata { + AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => { + Some(aaguid) + } + _ => None, + }; + + let credential_json = serde_json::to_value(&passkey) + .context("failed to serialise passkey") + .map_err(AppError::internal)?; + + let new_passkey = NewUserPasskey { + id: Uuid::new_v4(), + user_id: user.id, + credential_id: credential_id_vec, + public_key: public_key_bytes, + credential: credential_json, + sign_count: credential_struct.counter as i64, + transports, + aaguid, + nickname, + }; + + diesel::insert_into(passkey_dsl::user_passkeys) + .values(&new_passkey) + .execute(conn)?; + + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + + let created: UserPasskey = passkey_dsl::user_passkeys + .find(new_passkey.id) + .select(UserPasskey::as_select()) + .first(conn)?; + + Ok(created) + } + + pub fn start_authentication( + &self, + conn: &mut PgConnection, + user: &User, + ) -> AppResult { + self.prune_expired(conn); + + let stored: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user.id)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(UserPasskey::as_select()) + .load(conn)?; + + if stored.is_empty() { + return Err(AppError::bad_request("no passkeys registered")); + } + + let mut passkeys = Vec::with_capacity(stored.len()); + for pk in &stored { + let passkey: Passkey = serde_json::from_value(pk.credential.clone()) + .context("failed to parse stored passkey") + .map_err(AppError::internal)?; + passkeys.push(passkey); + } + + let (challenge, state) = self + .webauthn + .start_passkey_authentication(&passkeys) + .map_err(|err| { + tracing::error!(error = %err, "failed to start passkey authentication"); + AppError::internal("failed to start passkey authentication") + })?; + + let challenge_id = Uuid::new_v4(); + let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); + let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); + let state_bytes = serde_json::to_vec(&state) + .context("failed to encode authentication state") + .map_err(AppError::internal)?; + + let record = NewWebauthnChallenge { + id: challenge_id, + user_id: Some(user.id), + purpose: PURPOSE_AUTHENTICATION.to_string(), + challenge: challenge_bytes, + state: state_bytes, + expires_at, + }; + + diesel::insert_into(challenge_dsl::webauthn_challenges) + .values(&record) + .execute(conn)?; + + Ok(AuthenticationChallengeResponse { + challenge_id, + challenge, + }) + } + + pub fn list_for_user( + &self, + conn: &mut PgConnection, + user_id: Uuid, + ) -> AppResult> { + let passkeys: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .order(passkey_dsl::created_at.asc()) + .select(UserPasskey::as_select()) + .load(conn)?; + + Ok(passkeys.into_iter().map(PasskeySummary::from).collect()) + } + + pub fn revoke_passkey( + &self, + conn: &mut PgConnection, + user_id: Uuid, + passkey_id: Uuid, + reason: Option, + ) -> AppResult<()> { + let now = Utc::now().naive_utc(); + let updated = diesel::update( + passkey_dsl::user_passkeys + .filter(passkey_dsl::id.eq(passkey_id)) + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::revoked_at.is_null()), + ) + .set(( + passkey_dsl::revoked_at.eq(Some(now)), + passkey_dsl::revoked_reason.eq(reason), + passkey_dsl::updated_at.eq(now), + )) + .execute(conn)?; + + if updated == 0 { + return Err(AppError::not_found()); + } + + Ok(()) + } + + pub fn finish_authentication( + &self, + conn: &mut PgConnection, + challenge_id: Uuid, + credential: PublicKeyCredential, + ) -> AppResult<(User, UserPasskey, AuthenticationResult)> { + let record: WebauthnChallenge = challenge_dsl::webauthn_challenges + .find(challenge_id) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::bad_request("challenge not found") + } else { + AppError::from(err) + } + })?; + + if record.purpose != PURPOSE_AUTHENTICATION { + return Err(AppError::bad_request("challenge is not for authentication")); + } + + let user_id = record + .user_id + .ok_or_else(|| AppError::bad_request("challenge missing user context"))?; + + if record.expires_at < Utc::now().naive_utc() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::bad_request("challenge expired")); + } + + let state: PasskeyAuthentication = serde_json::from_slice(&record.state) + .context("failed to decode authentication state") + .map_err(AppError::internal)?; + + let auth_result = self + .webauthn + .finish_passkey_authentication(&credential, &state) + .map_err(|err| { + tracing::warn!(error = %err, "passkey authentication failed"); + AppError::unauthorized() + })?; + + let credential_id_vec: Vec = auth_result.cred_id().clone().into(); + + let mut passkey: UserPasskey = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(UserPasskey::as_select()) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::unauthorized() + } else { + AppError::from(err) + } + })?; + + let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone()) + .context("failed to parse stored passkey") + .map_err(AppError::internal)?; + + if auth_result.needs_update() { + let _ = passkey_model.update_credential(&auth_result); + } + + let credential_struct: Credential = passkey_model.clone().into(); + let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) + .context("failed to encode credential public key") + .map_err(AppError::internal)?; + + let transports: Vec> = credential_struct + .transports + .clone() + .unwrap_or_default() + .into_iter() + .map(|transport| Some(transport.as_ref().to_string())) + .collect(); + + let credential_json = serde_json::to_value(&passkey_model) + .context("failed to serialise passkey") + .map_err(AppError::internal)?; + + let now = Utc::now().naive_utc(); + diesel::update(passkey_dsl::user_passkeys.find(passkey.id)) + .set(( + passkey_dsl::sign_count.eq(auth_result.counter() as i64), + passkey_dsl::transports.eq(&transports), + passkey_dsl::credential.eq(credential_json.clone()), + passkey_dsl::public_key.eq(public_key_bytes), + passkey_dsl::last_used_at.eq(Some(now)), + passkey_dsl::updated_at.eq(now), + )) + .execute(conn)?; + + passkey.sign_count = auth_result.counter() as i64; + passkey.transports = transports; + passkey.credential = credential_json; + passkey.last_used_at = Some(now); + passkey.updated_at = now; + + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + + let user = crate::schema::users::table + .find(user_id) + .first::(conn)?; + + Ok((user, passkey, auth_result)) + } +} + +impl From for PasskeySummary { + fn from(passkey: UserPasskey) -> Self { + let transports = passkey + .transports + .into_iter() + .filter_map(|value| value) + .collect(); + + Self { + id: passkey.id, + nickname: passkey.nickname, + created_at: passkey.created_at, + last_used_at: passkey.last_used_at, + transports, + revoked_at: passkey.revoked_at, + revoked_reason: passkey.revoked_reason, + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyRegistrationFinishPayload { + pub challenge_id: Uuid, + pub credential: RegisterPublicKeyCredential, + #[serde(default)] + pub nickname: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyLoginStartPayload { + pub username: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyLoginFinishPayload { + pub challenge_id: Uuid, + pub credential: PublicKeyCredential, +} diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs index 19deca1..823333c 100644 --- a/backend/src/bin/admin.rs +++ b/backend/src/bin/admin.rs @@ -49,7 +49,6 @@ enum Command { AddUserToTenant { username: String, slug: String, - role: Option, }, RemoveUserFromTenant { username: String, @@ -73,7 +72,7 @@ impl Command { delete-user \n\ create-tenant [storage_root] [quickwit_index]\n\ delete-tenant \n\ - add-user-to-tenant [role]\n\ + add-user-to-tenant \n\ remove-user-from-tenant \n\ reanalyze-documents \n\ list-tenants\n\ @@ -108,7 +107,6 @@ impl Command { Some("add-user-to-tenant") => Ok(Self::AddUserToTenant { username: args.next().ok_or_else(|| anyhow!("username required"))?, slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?, - role: args.next(), }), Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant { username: args.next().ok_or_else(|| anyhow!("username required"))?, @@ -150,11 +148,7 @@ async fn main() -> Result<()> { quickwit_index, } => create_tenant(&pool, &slug, storage_root, quickwit_index)?, Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?, - Command::AddUserToTenant { - username, - slug, - role, - } => add_user_to_tenant(&pool, &username, &slug, role.as_deref())?, + Command::AddUserToTenant { username, slug } => add_user_to_tenant(&pool, &username, &slug)?, Command::RemoveUserFromTenant { username, slug } => { remove_user_from_tenant(&pool, &username, &slug)? } @@ -232,21 +226,22 @@ fn list_users(pool: &PgPool) -> Result<()> { } for user in users_list { - let memberships: Vec<(Uuid, String, String)> = user_memberships::table + let memberships: Vec = user_memberships::table .inner_join(tenants::table) .filter(user_memberships::user_id.eq(user.id)) - .select((tenants::id, tenants::slug, user_memberships::role)) - .order((tenants::slug.asc(), user_memberships::role.asc())) + .select(tenants::slug) + .order(tenants::slug.asc()) .load(&mut conn)?; if memberships.is_empty() { println!("{} ({})", user.username, user.id); } else { - let details: Vec = memberships - .into_iter() - .map(|(_, slug, role)| format!("{}: {}", slug, role)) - .collect(); - println!("{} ({}) -> {}", user.username, user.id, details.join(", ")); + println!( + "{} ({}) -> {}", + user.username, + user.id, + memberships.join(", ") + ); } } @@ -324,7 +319,7 @@ fn delete_tenant(pool: &PgPool, slug: &str) -> Result<()> { Ok(()) } -fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&str>) -> Result<()> { +fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; let user: User = users::table @@ -343,20 +338,15 @@ fn add_user_to_tenant(pool: &PgPool, username: &str, slug: &str, role: Option<&s id: Uuid::new_v4(), user_id: user.id, tenant_id: tenant.id, - role: role.unwrap_or("user").to_string(), }; diesel::insert_into(user_memberships::table) .values(&membership) .on_conflict((user_memberships::user_id, user_memberships::tenant_id)) - .do_update() - .set(user_memberships::role.eq(&membership.role)) + .do_nothing() .execute(&mut conn)?; - println!( - "added user '{}' to tenant '{}' with role '{}'", - username, slug, membership.role - ); + println!("added user '{}' to tenant '{}'", username, slug); Ok(()) } diff --git a/backend/src/config.rs b/backend/src/config.rs index a74d245..7ce0991 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -58,6 +58,12 @@ pub struct AppConfig { pub default_tenant_slug: String, #[serde(default = "default_worker_max_document_bytes")] pub worker_max_document_bytes: u64, + #[serde(default)] + pub webauthn_rp_id: Option, + #[serde(default)] + pub webauthn_origin: Option, + #[serde(default = "default_webauthn_rp_name")] + pub webauthn_rp_name: String, } impl AppConfig { @@ -69,6 +75,7 @@ impl AppConfig { database_url = %config.redacted_database_url(), pool_size = config.database_max_pool_size, quickwit_enabled = config.quickwit_endpoint.is_some(), + passkeys_enabled = config.webauthn_origin.is_some(), s3_bucket = %config.s3_bucket, worker_max_document_bytes = config.worker_max_document_bytes, "loaded backend configuration" @@ -92,6 +99,22 @@ impl AppConfig { if self.webdav_host.is_empty() { self.webdav_host = self.server_host.clone(); } + + if self.webauthn_rp_id.is_none() { + self.webauthn_rp_id = Some(self.server_host.clone()); + } + + if self.webauthn_origin.is_none() { + let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" { + "http" + } else { + "https" + }; + self.webauthn_origin = Some(format!( + "{scheme}://{}:{}", + self.server_host, self.server_port + )); + } self } } @@ -156,6 +179,10 @@ fn default_worker_max_document_bytes() -> u64 { 200 * 1024 * 1024 } +fn default_webauthn_rp_name() -> String { + "Papercrate".to_string() +} + fn redact_database_url(raw: &str) -> String { match Url::parse(raw) { Ok(mut parsed) => { diff --git a/backend/src/models.rs b/backend/src/models.rs index 2c01896..dd92a9f 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -21,7 +21,6 @@ pub struct UserMembership { pub id: Uuid, pub user_id: Uuid, pub tenant_id: Uuid, - pub role: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, } @@ -32,7 +31,6 @@ pub struct NewUserMembership { pub id: Uuid, pub user_id: Uuid, pub tenant_id: Uuid, - pub role: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)] @@ -127,6 +125,65 @@ pub struct NewUser { pub password_hash: String, } +#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)] +#[diesel(table_name = user_passkeys)] +#[diesel(belongs_to(User))] +pub struct UserPasskey { + pub id: Uuid, + pub user_id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, + pub nickname: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub last_used_at: Option, + pub revoked_at: Option, + pub revoked_by: Option, + pub revoked_reason: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = user_passkeys)] +pub struct NewUserPasskey { + pub id: Uuid, + pub user_id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, + pub nickname: Option, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = webauthn_challenges)] +#[diesel(belongs_to(User))] +pub struct WebauthnChallenge { + pub id: Uuid, + pub user_id: Option, + pub purpose: String, + pub challenge: Vec, + pub state: Vec, + pub created_at: NaiveDateTime, + pub expires_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = webauthn_challenges)] +pub struct NewWebauthnChallenge { + pub id: Uuid, + pub user_id: Option, + pub purpose: String, + pub challenge: Vec, + pub state: Vec, + pub expires_at: NaiveDateTime, +} + #[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[diesel(table_name = webdav_tokens)] #[diesel(belongs_to(User))] diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index ca4ea0d..49b3693 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -14,6 +14,10 @@ use uuid::Uuid; doc::me, doc::list_tenants, doc::select_tenant, + doc::passkey_register_start, + doc::passkey_register_finish, + doc::passkey_login_start, + doc::passkey_login_finish, doc::list_documents, doc::check_document, doc::upload_document, @@ -50,6 +54,8 @@ use uuid::Uuid; doc::list_webdav_tokens, doc::create_webdav_token, doc::delete_webdav_token, + doc::list_passkeys, + doc::delete_passkey, ), components( schemas( @@ -61,6 +67,12 @@ use uuid::Uuid; schemas::TenantSelectionRequest, schemas::TenantListResponse, schemas::LoginResponseVariants, + schemas::RegistrationChallengeResponse, + schemas::AuthenticationChallengeResponse, + schemas::PasskeySummary, + schemas::PasskeyRegistrationFinishPayload, + schemas::PasskeyLoginStartPayload, + schemas::PasskeyLoginFinishPayload, schemas::DocumentResponse, schemas::DocumentDetailResponse, schemas::DocumentVersionResponse, @@ -210,6 +222,44 @@ mod doc { )] pub(super) fn select_tenant() {} + #[utoipa::path( + post, + path = "/api/auth/passkeys/register/start", + responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)), + tag = "Auth" + )] + pub(super) fn passkey_register_start() {} + + #[utoipa::path( + post, + path = "/api/auth/passkeys/register/finish", + request_body = PasskeyRegistrationFinishPayload, + responses((status = 200, description = "Passkey registered", body = PasskeySummary)), + tag = "Auth" + )] + pub(super) fn passkey_register_finish() {} + + #[utoipa::path( + post, + path = "/api/auth/passkeys/login/start", + request_body = PasskeyLoginStartPayload, + responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)), + tag = "Auth" + )] + pub(super) fn passkey_login_start() {} + + #[utoipa::path( + post, + path = "/api/auth/passkeys/login/finish", + request_body = PasskeyLoginFinishPayload, + responses( + (status = 200, description = "Passkey login successful", body = LoginResponseVariants), + (status = 401, description = "Authentication failed") + ), + tag = "Auth" + )] + pub(super) fn passkey_login_finish() {} + #[utoipa::path( get, path = "/api/documents", @@ -587,6 +637,26 @@ mod doc { tag = "Profile" )] pub(super) fn delete_webdav_token() {} + + #[utoipa::path( + get, + path = "/api/profile/passkeys", + responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])), + tag = "Profile" + )] + pub(super) fn list_passkeys() {} + + #[utoipa::path( + delete, + path = "/api/profile/passkeys/{id}", + params( + ("id" = Uuid, Path, description = "Passkey ID"), + ("reason" = Option, Query, description = "Optional reason for revoking the passkey") + ), + responses((status = 204, description = "Passkey revoked")), + tag = "Profile" + )] + pub(super) fn delete_passkey() {} } #[cfg(test)] @@ -604,6 +674,11 @@ mod tests { pub mod schemas { use super::*; + pub use crate::auth::passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }; + #[derive(Serialize, Deserialize, ToSchema)] pub struct LoginRequest { pub username: String, diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 9ec9952..a8d1db4 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -16,7 +16,13 @@ use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::{ - auth::{password, AuthenticatedUser}, + auth::{ + passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }, + password, AuthenticatedUser, + }, error::{AppError, AppResult}, models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership}, schema::{ @@ -141,54 +147,13 @@ pub async fn login( return Err(AppError::unauthorized()); } - let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships - .inner_join(tenant_dsl::tenants) - .filter(memberships_dsl::user_id.eq(user.id)) - .load(&mut conn)?; - - if memberships.is_empty() { - return Err(AppError::unauthorized()); - } - let preferred_slug = payload .preferred_tenant_slug .as_ref() .map(|slug| slug.trim().to_string()) .filter(|slug| !slug.is_empty()); - if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| { - memberships - .iter() - .find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug)) - }) { - return issue_session(&state, &mut conn, &user, tenant.1.id); - } - - if memberships.len() == 1 { - let tenant_id = memberships[0].1.id; - return issue_session(&state, &mut conn, &user, tenant_id); - } - - let selection_token = state - .jwt - .generate_tenant_selector_token(user.id) - .map_err(AppError::from)?; - - let tenants = memberships - .into_iter() - .map(|(_, tenant)| TenantSnippet { - id: tenant.id, - slug: tenant.slug, - }) - .collect(); - - let response = Json(TenantSelectionResponse { - access_token: selection_token, - tenants, - }) - .into_response(); - - Ok(response) + complete_login(&state, &mut conn, &user, preferred_slug) } pub async fn refresh( @@ -367,6 +332,142 @@ pub async fn list_tenants( Ok(Json(TenantListResponse { tenants })) } +pub async fn passkey_register_start( + State(state): State, + user: AuthenticatedUser, +) -> AppResult> { + let service = state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = state.db_unscoped()?; + let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; + let challenge = service.start_registration(&mut conn, ¤t_user)?; + Ok(Json(challenge)) +} + +pub async fn passkey_register_finish( + State(state): State, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + let service = state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = state.db_unscoped()?; + let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; + + let PasskeyRegistrationFinishPayload { + challenge_id, + credential, + nickname, + } = payload; + + let passkey = service.finish_registration( + &mut conn, + ¤t_user, + challenge_id, + credential, + nickname, + )?; + + Ok(Json(PasskeySummary::from(passkey))) +} + +pub async fn passkey_login_start( + State(state): State, + Json(payload): Json, +) -> AppResult> { + let service = state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let username = payload.username.trim(); + if username.is_empty() { + return Err(AppError::bad_request("username must not be empty")); + } + + let mut conn = state.db_unscoped()?; + let user: User = dsl::users + .filter(dsl::username.eq(username)) + .first(&mut conn)?; + + let challenge = service.start_authentication(&mut conn, &user)?; + Ok(Json(challenge)) +} + +pub async fn passkey_login_finish( + State(state): State, + Json(payload): Json, +) -> AppResult { + let service = state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = state.db_unscoped()?; + let (user, _passkey, auth_result) = + service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?; + + if !auth_result.user_verified() { + return Err(AppError::unauthorized()); + } + + complete_login(&state, &mut conn, &user, None) +} + +fn complete_login( + state: &AppState, + conn: &mut PgConnection, + user: &User, + preferred_slug: Option, +) -> AppResult { + let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships + .inner_join(tenant_dsl::tenants) + .filter(memberships_dsl::user_id.eq(user.id)) + .load(conn)?; + + if memberships.is_empty() { + return Err(AppError::unauthorized()); + } + + if let Some(ref slug) = preferred_slug { + if let Some((_, tenant)) = memberships + .iter() + .find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug)) + { + return issue_session(state, conn, user, tenant.id); + } + } + + if memberships.len() == 1 { + return issue_session(state, conn, user, memberships[0].1.id); + } + + let selection_token = state + .jwt + .generate_tenant_selector_token(user.id) + .map_err(AppError::from)?; + + let tenants = memberships + .into_iter() + .map(|(_, tenant)| TenantSnippet { + id: tenant.id, + slug: tenant.slug, + }) + .collect(); + + Ok(Json(TenantSelectionResponse { + access_token: selection_token, + tenants, + }) + .into_response()) +} + fn issue_session( state: &AppState, conn: &mut PgConnection, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 7f68ccc..fc4dea4 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -60,6 +60,16 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/logout", post(auth::logout)) .route("/select-tenant", post(auth::select_tenant)) .route("/tenants", get(auth::list_tenants)) + .route( + "/passkeys/register/start", + post(auth::passkey_register_start), + ) + .route( + "/passkeys/register/finish", + post(auth::passkey_register_finish), + ) + .route("/passkeys/login/start", post(auth::passkey_login_start)) + .route("/passkeys/login/finish", post(auth::passkey_login_finish)) .route("/me", get(auth::me)); let documents_routes = Router::new() @@ -137,7 +147,9 @@ pub fn create_router(state: AppState) -> Router<()> { "/webdav-tokens", get(profile::list_webdav_tokens).post(profile::create_webdav_token), ) - .route("/webdav-tokens/:id", delete(profile::delete_webdav_token)); + .route("/webdav-tokens/:id", delete(profile::delete_webdav_token)) + .route("/passkeys", get(profile::list_passkeys)) + .route("/passkeys/:id", delete(profile::delete_passkey)); let protected_state = state.clone(); let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset)); diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index d5fb107..9d7ff16 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -1,9 +1,14 @@ -use axum::{extract::Path, http::StatusCode, Json}; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; use chrono::{DateTime, NaiveDateTime}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::auth::{ + passkeys::PasskeySummary, webdav_tokens::{ create_webdav_token as issue_token, list_webdav_tokens as load_tokens, revoke_webdav_token as revoke_token, @@ -12,6 +17,7 @@ use crate::auth::{ }; use crate::error::{AppError, AppResult}; use crate::models::WebdavToken; +use crate::state::AppState; use crate::utils::{db::no_content, time::to_iso}; #[derive(Debug, Serialize)] @@ -37,6 +43,27 @@ pub struct CreateWebdavTokenRequest { pub expires_at: Option, } +#[derive(Debug, Deserialize)] +pub struct RevokePasskeyQuery { + #[serde(default)] + pub reason: Option, +} + +pub async fn list_passkeys( + State(state): State, + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, +) -> AppResult>> { + let service = state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let passkeys = service.list_for_user(&mut conn, user_id)?; + Ok(Json(passkeys)) +} + pub async fn list_webdav_tokens( TenantScopedConn { mut conn, @@ -90,6 +117,23 @@ pub async fn delete_webdav_token( no_content() } +pub async fn delete_passkey( + State(state): State, + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, + Path(passkey_id): Path, + Query(query): Query, +) -> AppResult { + let service = state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?; + no_content() +} + fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse { WebdavTokenResponse { id: token.id, diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 444577b..9c54d30 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -175,12 +175,31 @@ diesel::table! { id -> Uuid, user_id -> Uuid, tenant_id -> Uuid, - role -> Text, created_at -> Timestamptz, updated_at -> Timestamptz, } } +diesel::table! { + user_passkeys (id) { + id -> Uuid, + user_id -> Uuid, + credential_id -> Bytea, + public_key -> Bytea, + credential -> Jsonb, + sign_count -> Int8, + transports -> Array>, + aaguid -> Nullable, + nickname -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + last_used_at -> Nullable, + revoked_at -> Nullable, + revoked_by -> Nullable, + revoked_reason -> Nullable, + } +} + diesel::table! { users (id) { id -> Uuid, @@ -193,6 +212,18 @@ diesel::table! { } } +diesel::table! { + webauthn_challenges (id) { + id -> Uuid, + user_id -> Nullable, + purpose -> Text, + challenge -> Bytea, + state -> Bytea, + created_at -> Timestamptz, + expires_at -> Timestamptz, + } +} + diesel::table! { webdav_tokens (id) { id -> Uuid, @@ -231,6 +262,8 @@ diesel::joinable!(refresh_tokens -> users (user_id)); diesel::joinable!(tags -> tenants (tenant_id)); diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> users (user_id)); +diesel::joinable!(user_passkeys -> users (user_id)); +diesel::joinable!(webauthn_challenges -> users (user_id)); diesel::joinable!(webdav_tokens -> tenants (tenant_id)); diesel::joinable!(webdav_tokens -> users (user_id)); @@ -248,6 +281,8 @@ diesel::allow_tables_to_appear_in_same_query!( tags, tenants, user_memberships, + user_passkeys, users, + webauthn_challenges, webdav_tokens, ); diff --git a/backend/src/state.rs b/backend/src/state.rs index 986e96e..3aeb0ca 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -7,7 +7,7 @@ use diesel::{ use uuid::Uuid; use crate::{ - auth::jwt::JwtService, + auth::{jwt::JwtService, passkeys::PasskeyService}, config::AppConfig, db::PgPool, error::{AppError, AppResult}, @@ -24,6 +24,7 @@ pub struct AppState { storage: Arc, pub jwt: JwtService, pub tenants: TenantService, + pub passkeys: Option, } impl AppState { @@ -52,12 +53,21 @@ impl AppState { let config = Arc::new(config); let tenants = TenantService::new(pool.clone()); + let passkeys = match PasskeyService::try_new(&config) { + Ok(service) => service, + Err(err) => { + tracing::warn!(error = ?err, "passkey service disabled due to configuration"); + None + } + }; + Self { pool, config, storage, jwt, tenants, + passkeys, } } diff --git a/backend/src/workers/tenants.rs b/backend/src/workers/tenants.rs index 988fa16..0be799a 100644 --- a/backend/src/workers/tenants.rs +++ b/backend/src/workers/tenants.rs @@ -121,7 +121,6 @@ impl JobHandler for ProvisionTenantJob { id: Uuid::new_v4(), user_id: member, tenant_id: tenant.id, - role: "admin".to_string(), }; if let Err(err) = diesel::insert_into(user_memberships::table) diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index f87cac5..1d09c2d 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -3,14 +3,23 @@ mod common; use anyhow::{anyhow, Context, Result}; use axum::body::Body; use axum::http::{header::SET_COOKIE, StatusCode}; +use backend::auth::passkeys::{ + PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, + RegistrationChallengeResponse, +}; use backend::jobs::JOB_PROVISION_TENANT; -use backend::models::{Job, NewUserMembership, TenantStatus}; +use backend::models::{Job, NewUserMembership, TenantStatus, UserPasskey}; +use backend::openapi::schemas::PasskeySummary; use backend::schema::{jobs, tenants, user_memberships, users}; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; use serde::Deserialize; use serde_json::json; use uuid::Uuid; +use webauthn_rs_core::proto::{ + AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, + RegisterPublicKeyCredential, +}; #[derive(Deserialize)] struct AuthenticatedUser { @@ -101,7 +110,7 @@ async fn signup_creates_user_tenant_and_membership() -> Result<()> { let me = app.get("/api/auth/me", Some(&login.access_token)).await?; assert_eq!(me.status(), StatusCode::OK); - app.with_conn(|conn| { + app.with_conn(move |conn| { let user: backend::models::User = users::table .filter(users::username.eq("signup-user")) .first(conn)?; @@ -153,6 +162,227 @@ async fn signup_creates_user_tenant_and_membership() -> Result<()> { Ok(()) } +#[tokio::test] +async fn passkey_register_start_creates_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + app.insert_user("passkey-user", password, "admin").await?; + + let (login, _) = login_with_session(&app, "passkey-user", password).await?; + + let response = app + .post_json( + "/api/auth/passkeys/register/start", + &json!({}), + Some(&login.access_token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?; + assert_ne!(challenge.challenge_id, Uuid::nil()); + let challenge_id = challenge.challenge_id; + + app.with_conn(move |conn| { + use backend::schema::webauthn_challenges::dsl; + use diesel::dsl::{exists, select}; + + let exists: bool = select(exists( + dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)), + )) + .get_result(conn)?; + assert!(exists, "challenge not persisted"); + Ok(()) + }) + .await?; + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + app.insert_user("passkey-register", password, "admin") + .await?; + let (login, _) = login_with_session(&app, "passkey-register", password).await?; + + let payload = PasskeyRegistrationFinishPayload { + challenge_id: Uuid::new_v4(), + credential: fake_register_credential(), + nickname: None, + }; + + let response = app + .post_json( + "/api/auth/passkeys/register/finish", + &payload, + Some(&login.access_token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_start_requires_passkey() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + app.insert_user("passkey-login", password, "admin").await?; + + let payload = PasskeyLoginStartPayload { + username: "passkey-login".to_string(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/start", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = PasskeyLoginStartPayload { + username: "nobody".to_string(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/start", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = PasskeyLoginFinishPayload { + challenge_id: Uuid::new_v4(), + credential: fake_authentication_credential(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/finish", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn list_passkeys_returns_entries() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app.insert_user("passkey-owner", password, "admin").await?; + app.insert_passkey(user_id, Some("Laptop")).await?; + + let (session, _) = login_with_session(&app, "passkey-owner", password).await?; + + let response = app + .get("/api/profile/passkeys", Some(&session.access_token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let body = body_to_vec(response.into_body()).await?; + let summaries: Vec = serde_json::from_slice(&body)?; + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop")); + assert!(summaries[0].revoked_at.is_none()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_passkey_soft_revokes() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app.insert_user("passkey-delete", password, "admin").await?; + let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?; + let (session, _) = login_with_session(&app, "passkey-delete", password).await?; + + let response = app + .delete( + &format!("/api/profile/passkeys/{}?reason=lost", passkey_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + app.with_conn(move |conn| { + use backend::schema::user_passkeys::dsl as passkey_dsl; + + let record = passkey_dsl::user_passkeys + .find(passkey_id) + .first::(conn)?; + assert!(record.revoked_at.is_some()); + assert_eq!(record.revoked_reason.as_deref(), Some("lost")); + Ok(()) + }) + .await?; + + app.cleanup().await?; + Ok(()) +} + +fn fake_register_credential() -> RegisterPublicKeyCredential { + RegisterPublicKeyCredential { + id: "fake-passkey".to_string(), + raw_id: vec![1, 2, 3, 4].into(), + response: AuthenticatorAttestationResponseRaw { + attestation_object: vec![5, 6, 7, 8].into(), + client_data_json: vec![9, 10, 11, 12].into(), + transports: None, + }, + type_: "public-key".to_string(), + extensions: Default::default(), + } +} + +fn fake_authentication_credential() -> PublicKeyCredential { + PublicKeyCredential { + id: "fake-auth".to_string(), + raw_id: vec![1, 2, 3].into(), + response: AuthenticatorAssertionResponseRaw { + authenticator_data: vec![4, 5, 6].into(), + client_data_json: vec![7, 8, 9].into(), + signature: vec![10, 11, 12].into(), + user_handle: None, + }, + extensions: Default::default(), + type_: "public-key".to_string(), + } +} + #[tokio::test] async fn login_rejects_invalid_password() -> Result<()> { let _lock = acquire_db_lock().await; @@ -278,7 +508,6 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<() id: Uuid::new_v4(), user_id, tenant_id: secondary_id, - role: "admin".to_string(), }; diesel::insert_into(user_memberships::table) diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 77fb72a..3e80dd0 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -11,7 +11,7 @@ use axum::Router; use backend::auth::jwt::JwtService; use backend::config::AppConfig; use backend::db::{self, PgPool}; -use backend::models::{Job, NewUser, NewUserMembership, Tenant, TenantStatus}; +use backend::models::{Job, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus}; use backend::routes; use backend::state::AppState; use backend::storage::ObjectStorage; @@ -24,7 +24,7 @@ use http_body_util::BodyExt; use once_cell::sync::Lazy; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; -use serde_json; +use serde_json::{self, json}; use tokio::sync::Mutex; use tower::util::ServiceExt; use uuid::Uuid; @@ -142,6 +142,9 @@ impl TestApp { quickwit_index: None, default_tenant_slug: "admin".to_string(), worker_max_document_bytes: 200 * 1024 * 1024, + webauthn_rp_id: Some("localhost".to_string()), + webauthn_origin: Some("http://localhost".to_string()), + webauthn_rp_name: "Papercrate".to_string(), }; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; @@ -199,10 +202,9 @@ impl TestApp { Ok(format!("{}{}", root, key)) } - pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result { + pub async fn insert_user(&self, username: &str, password: &str, _role: &str) -> Result { let username = username.to_string(); let password = password.to_string(); - let role = role.to_string(); let tenant_id = self .state .tenants @@ -224,7 +226,6 @@ impl TestApp { id: Uuid::new_v4(), user_id: user.id, tenant_id, - role, }; diesel::insert_into(backend::schema::user_memberships::table) @@ -236,6 +237,32 @@ impl TestApp { .await } + pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result { + let passkey_id = Uuid::new_v4(); + let nickname = nickname.map(|value| value.to_string()); + self.with_conn(move |conn| { + let passkey = NewUserPasskey { + id: passkey_id, + user_id, + credential_id: vec![1, 2, 3], + public_key: vec![4, 5, 6], + credential: json!({ "dummy": true }), + sign_count: 0, + transports: vec![Some("usb".to_string())], + aaguid: None, + nickname, + }; + + diesel::insert_into(backend::schema::user_passkeys::table) + .values(&passkey) + .execute(conn) + .context("failed to insert passkey")?; + + Ok(passkey_id) + }) + .await + } + async fn ensure_default_tenant(&self) -> Result { let slug_value = self.state.config.default_tenant_slug.clone(); let quickwit_enabled = self.state.config.quickwit_endpoint.is_some(); diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs index e1ba0be..07b40dd 100644 --- a/backend/tests/tags_flow.rs +++ b/backend/tests/tags_flow.rs @@ -231,7 +231,6 @@ async fn tags_are_isolated_between_tenants() -> Result<()> { id: Uuid::new_v4(), user_id: user_b_id, tenant_id: tenant_b_id, - role: "admin".to_string(), }; diesel::insert_into(memberships_dsl::user_memberships) .values(&membership)