feat(auth): add WebAuthn passkey support with migrations, routes, and tests
This commit is contained in:
Generated
+273
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS user_passkeys;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS webauthn_challenges;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_memberships
|
||||
ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_memberships
|
||||
DROP COLUMN role;
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod jwt;
|
||||
pub mod passkeys;
|
||||
pub mod password;
|
||||
pub mod webdav_tokens;
|
||||
|
||||
|
||||
@@ -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<Webauthn>,
|
||||
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<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub last_used_at: Option<NaiveDateTime>,
|
||||
pub transports: Vec<String>,
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub revoked_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl PasskeyService {
|
||||
pub fn try_new(config: &AppConfig) -> Result<Option<Self>> {
|
||||
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<RegistrationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
let existing: Vec<UserPasskey> = 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<u8> = 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<String>,
|
||||
) -> AppResult<UserPasskey> {
|
||||
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<u8> = credential_struct.cred_id.clone().into();
|
||||
|
||||
let duplicate = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
|
||||
.first::<UserPasskey>(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<Option<String>> = 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<AuthenticationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
let stored: Vec<UserPasskey> = 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<u8> = 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<Vec<PasskeySummary>> {
|
||||
let passkeys: Vec<UserPasskey> = 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<String>,
|
||||
) -> 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<u8> = 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<Option<String>> = 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::<User>(conn)?;
|
||||
|
||||
Ok((user, passkey, auth_result))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserPasskey> 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<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
+14
-24
@@ -49,7 +49,6 @@ enum Command {
|
||||
AddUserToTenant {
|
||||
username: String,
|
||||
slug: String,
|
||||
role: Option<String>,
|
||||
},
|
||||
RemoveUserFromTenant {
|
||||
username: String,
|
||||
@@ -73,7 +72,7 @@ impl Command {
|
||||
delete-user <username>\n\
|
||||
create-tenant <slug> [storage_root] [quickwit_index]\n\
|
||||
delete-tenant <slug>\n\
|
||||
add-user-to-tenant <username> <slug> [role]\n\
|
||||
add-user-to-tenant <username> <slug>\n\
|
||||
remove-user-from-tenant <username> <slug>\n\
|
||||
reanalyze-documents <slug>\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<String> = 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<String> = 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub webauthn_origin: Option<String>,
|
||||
#[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) => {
|
||||
|
||||
+59
-2
@@ -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<u8>,
|
||||
pub public_key: Vec<u8>,
|
||||
pub credential: serde_json::Value,
|
||||
pub sign_count: i64,
|
||||
pub transports: Vec<Option<String>>,
|
||||
pub aaguid: Option<Uuid>,
|
||||
pub nickname: Option<String>,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub last_used_at: Option<NaiveDateTime>,
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub revoked_by: Option<Uuid>,
|
||||
pub revoked_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = user_passkeys)]
|
||||
pub struct NewUserPasskey {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub credential_id: Vec<u8>,
|
||||
pub public_key: Vec<u8>,
|
||||
pub credential: serde_json::Value,
|
||||
pub sign_count: i64,
|
||||
pub transports: Vec<Option<String>>,
|
||||
pub aaguid: Option<Uuid>,
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Uuid>,
|
||||
pub purpose: String,
|
||||
pub challenge: Vec<u8>,
|
||||
pub state: Vec<u8>,
|
||||
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<Uuid>,
|
||||
pub purpose: String,
|
||||
pub challenge: Vec<u8>,
|
||||
pub state: Vec<u8>,
|
||||
pub expires_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = webdav_tokens)]
|
||||
#[diesel(belongs_to(User))]
|
||||
|
||||
@@ -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<String>, 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,
|
||||
|
||||
+144
-43
@@ -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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<RegistrationChallengeResponse>> {
|
||||
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<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<PasskeyRegistrationFinishPayload>,
|
||||
) -> AppResult<Json<PasskeySummary>> {
|
||||
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<AppState>,
|
||||
Json(payload): Json<PasskeyLoginStartPayload>,
|
||||
) -> AppResult<Json<AuthenticationChallengeResponse>> {
|
||||
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<AppState>,
|
||||
Json(payload): Json<PasskeyLoginFinishPayload>,
|
||||
) -> AppResult<Response> {
|
||||
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<String>,
|
||||
) -> AppResult<Response> {
|
||||
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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RevokePasskeyQuery {
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_passkeys(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<PasskeySummary>>> {
|
||||
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<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
Path(passkey_id): Path<Uuid>,
|
||||
Query(query): Query<RevokePasskeyQuery>,
|
||||
) -> AppResult<StatusCode> {
|
||||
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,
|
||||
|
||||
+36
-1
@@ -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<Nullable<Text>>,
|
||||
aaguid -> Nullable<Uuid>,
|
||||
nickname -> Nullable<Text>,
|
||||
created_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
revoked_at -> Nullable<Timestamptz>,
|
||||
revoked_by -> Nullable<Uuid>,
|
||||
revoked_reason -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
users (id) {
|
||||
id -> Uuid,
|
||||
@@ -193,6 +212,18 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
webauthn_challenges (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Nullable<Uuid>,
|
||||
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,
|
||||
);
|
||||
|
||||
+11
-1
@@ -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<dyn ObjectStorage>,
|
||||
pub jwt: JwtService,
|
||||
pub tenants: TenantService,
|
||||
pub passkeys: Option<PasskeyService>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+232
-3
@@ -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<PasskeySummary> = 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::<UserPasskey>(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)
|
||||
|
||||
@@ -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<Uuid> {
|
||||
pub async fn insert_user(&self, username: &str, password: &str, _role: &str) -> Result<Uuid> {
|
||||
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<Uuid> {
|
||||
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<Uuid> {
|
||||
let slug_value = self.state.config.default_tenant_slug.clone();
|
||||
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user