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",
|
"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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -627,6 +666,8 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-aux",
|
"serde-aux",
|
||||||
|
"serde_bytes",
|
||||||
|
"serde_cbor_2",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -639,6 +680,8 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"webauthn-rs",
|
||||||
|
"webauthn-rs-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -690,6 +733,17 @@ version = "1.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
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]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.72.1"
|
version = "0.72.1"
|
||||||
@@ -939,6 +993,12 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-bigint"
|
name = "crypto-bigint"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -1006,6 +1066,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -1016,6 +1082,20 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -1255,6 +1335,21 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
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]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1429,6 +1524,17 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -2159,18 +2265,65 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
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]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
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]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -2231,6 +2384,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfium-render"
|
name = "pdfium-render"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -2707,6 +2866,15 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusticata-macros"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -2946,6 +3114,26 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -3801,6 +3989,74 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4043,6 +4299,23 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
|
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]]
|
[[package]]
|
||||||
name = "xmlparser"
|
name = "xmlparser"
|
||||||
version = "0.13.6"
|
version = "0.13.6"
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ anyhow = "1.0"
|
|||||||
# Authentication & security
|
# Authentication & security
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
jsonwebtoken = "9"
|
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
|
# Misc
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
@@ -64,3 +67,4 @@ rand = "0.8"
|
|||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
hyper = "1.2"
|
hyper = "1.2"
|
||||||
http-body-util = "0.1"
|
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 jwt;
|
||||||
|
pub mod passkeys;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod webdav_tokens;
|
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 {
|
AddUserToTenant {
|
||||||
username: String,
|
username: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
role: Option<String>,
|
|
||||||
},
|
},
|
||||||
RemoveUserFromTenant {
|
RemoveUserFromTenant {
|
||||||
username: String,
|
username: String,
|
||||||
@@ -73,7 +72,7 @@ impl Command {
|
|||||||
delete-user <username>\n\
|
delete-user <username>\n\
|
||||||
create-tenant <slug> [storage_root] [quickwit_index]\n\
|
create-tenant <slug> [storage_root] [quickwit_index]\n\
|
||||||
delete-tenant <slug>\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\
|
remove-user-from-tenant <username> <slug>\n\
|
||||||
reanalyze-documents <slug>\n\
|
reanalyze-documents <slug>\n\
|
||||||
list-tenants\n\
|
list-tenants\n\
|
||||||
@@ -108,7 +107,6 @@ impl Command {
|
|||||||
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
|
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
slug: args.next().ok_or_else(|| anyhow!("tenant slug required"))?,
|
||||||
role: args.next(),
|
|
||||||
}),
|
}),
|
||||||
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
|
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
|
||||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||||
@@ -150,11 +148,7 @@ async fn main() -> Result<()> {
|
|||||||
quickwit_index,
|
quickwit_index,
|
||||||
} => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
|
} => create_tenant(&pool, &slug, storage_root, quickwit_index)?,
|
||||||
Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
|
Command::DeleteTenant { slug } => delete_tenant(&pool, &slug)?,
|
||||||
Command::AddUserToTenant {
|
Command::AddUserToTenant { username, slug } => add_user_to_tenant(&pool, &username, &slug)?,
|
||||||
username,
|
|
||||||
slug,
|
|
||||||
role,
|
|
||||||
} => add_user_to_tenant(&pool, &username, &slug, role.as_deref())?,
|
|
||||||
Command::RemoveUserFromTenant { username, slug } => {
|
Command::RemoveUserFromTenant { username, slug } => {
|
||||||
remove_user_from_tenant(&pool, &username, &slug)?
|
remove_user_from_tenant(&pool, &username, &slug)?
|
||||||
}
|
}
|
||||||
@@ -232,21 +226,22 @@ fn list_users(pool: &PgPool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for user in users_list {
|
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)
|
.inner_join(tenants::table)
|
||||||
.filter(user_memberships::user_id.eq(user.id))
|
.filter(user_memberships::user_id.eq(user.id))
|
||||||
.select((tenants::id, tenants::slug, user_memberships::role))
|
.select(tenants::slug)
|
||||||
.order((tenants::slug.asc(), user_memberships::role.asc()))
|
.order(tenants::slug.asc())
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
if memberships.is_empty() {
|
if memberships.is_empty() {
|
||||||
println!("{} ({})", user.username, user.id);
|
println!("{} ({})", user.username, user.id);
|
||||||
} else {
|
} else {
|
||||||
let details: Vec<String> = memberships
|
println!(
|
||||||
.into_iter()
|
"{} ({}) -> {}",
|
||||||
.map(|(_, slug, role)| format!("{}: {}", slug, role))
|
user.username,
|
||||||
.collect();
|
user.id,
|
||||||
println!("{} ({}) -> {}", user.username, user.id, details.join(", "));
|
memberships.join(", ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +319,7 @@ fn delete_tenant(pool: &PgPool, slug: &str) -> Result<()> {
|
|||||||
Ok(())
|
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 mut conn = pool.get().context("failed to get database connection")?;
|
||||||
|
|
||||||
let user: User = users::table
|
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(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
role: role.unwrap_or("user").to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(user_memberships::table)
|
diesel::insert_into(user_memberships::table)
|
||||||
.values(&membership)
|
.values(&membership)
|
||||||
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
.on_conflict((user_memberships::user_id, user_memberships::tenant_id))
|
||||||
.do_update()
|
.do_nothing()
|
||||||
.set(user_memberships::role.eq(&membership.role))
|
|
||||||
.execute(&mut conn)?;
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
println!(
|
println!("added user '{}' to tenant '{}'", username, slug);
|
||||||
"added user '{}' to tenant '{}' with role '{}'",
|
|
||||||
username, slug, membership.role
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ pub struct AppConfig {
|
|||||||
pub default_tenant_slug: String,
|
pub default_tenant_slug: String,
|
||||||
#[serde(default = "default_worker_max_document_bytes")]
|
#[serde(default = "default_worker_max_document_bytes")]
|
||||||
pub worker_max_document_bytes: u64,
|
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 {
|
impl AppConfig {
|
||||||
@@ -69,6 +75,7 @@ impl AppConfig {
|
|||||||
database_url = %config.redacted_database_url(),
|
database_url = %config.redacted_database_url(),
|
||||||
pool_size = config.database_max_pool_size,
|
pool_size = config.database_max_pool_size,
|
||||||
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||||
|
passkeys_enabled = config.webauthn_origin.is_some(),
|
||||||
s3_bucket = %config.s3_bucket,
|
s3_bucket = %config.s3_bucket,
|
||||||
worker_max_document_bytes = config.worker_max_document_bytes,
|
worker_max_document_bytes = config.worker_max_document_bytes,
|
||||||
"loaded backend configuration"
|
"loaded backend configuration"
|
||||||
@@ -92,6 +99,22 @@ impl AppConfig {
|
|||||||
if self.webdav_host.is_empty() {
|
if self.webdav_host.is_empty() {
|
||||||
self.webdav_host = self.server_host.clone();
|
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
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +179,10 @@ fn default_worker_max_document_bytes() -> u64 {
|
|||||||
200 * 1024 * 1024
|
200 * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_webauthn_rp_name() -> String {
|
||||||
|
"Papercrate".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn redact_database_url(raw: &str) -> String {
|
fn redact_database_url(raw: &str) -> String {
|
||||||
match Url::parse(raw) {
|
match Url::parse(raw) {
|
||||||
Ok(mut parsed) => {
|
Ok(mut parsed) => {
|
||||||
|
|||||||
+59
-2
@@ -21,7 +21,6 @@ pub struct UserMembership {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@@ -32,7 +31,6 @@ pub struct NewUserMembership {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
pub role: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
|
||||||
@@ -127,6 +125,65 @@ pub struct NewUser {
|
|||||||
pub password_hash: String,
|
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)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
#[diesel(table_name = webdav_tokens)]
|
#[diesel(table_name = webdav_tokens)]
|
||||||
#[diesel(belongs_to(User))]
|
#[diesel(belongs_to(User))]
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ use uuid::Uuid;
|
|||||||
doc::me,
|
doc::me,
|
||||||
doc::list_tenants,
|
doc::list_tenants,
|
||||||
doc::select_tenant,
|
doc::select_tenant,
|
||||||
|
doc::passkey_register_start,
|
||||||
|
doc::passkey_register_finish,
|
||||||
|
doc::passkey_login_start,
|
||||||
|
doc::passkey_login_finish,
|
||||||
doc::list_documents,
|
doc::list_documents,
|
||||||
doc::check_document,
|
doc::check_document,
|
||||||
doc::upload_document,
|
doc::upload_document,
|
||||||
@@ -50,6 +54,8 @@ use uuid::Uuid;
|
|||||||
doc::list_webdav_tokens,
|
doc::list_webdav_tokens,
|
||||||
doc::create_webdav_token,
|
doc::create_webdav_token,
|
||||||
doc::delete_webdav_token,
|
doc::delete_webdav_token,
|
||||||
|
doc::list_passkeys,
|
||||||
|
doc::delete_passkey,
|
||||||
),
|
),
|
||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
@@ -61,6 +67,12 @@ use uuid::Uuid;
|
|||||||
schemas::TenantSelectionRequest,
|
schemas::TenantSelectionRequest,
|
||||||
schemas::TenantListResponse,
|
schemas::TenantListResponse,
|
||||||
schemas::LoginResponseVariants,
|
schemas::LoginResponseVariants,
|
||||||
|
schemas::RegistrationChallengeResponse,
|
||||||
|
schemas::AuthenticationChallengeResponse,
|
||||||
|
schemas::PasskeySummary,
|
||||||
|
schemas::PasskeyRegistrationFinishPayload,
|
||||||
|
schemas::PasskeyLoginStartPayload,
|
||||||
|
schemas::PasskeyLoginFinishPayload,
|
||||||
schemas::DocumentResponse,
|
schemas::DocumentResponse,
|
||||||
schemas::DocumentDetailResponse,
|
schemas::DocumentDetailResponse,
|
||||||
schemas::DocumentVersionResponse,
|
schemas::DocumentVersionResponse,
|
||||||
@@ -210,6 +222,44 @@ mod doc {
|
|||||||
)]
|
)]
|
||||||
pub(super) fn select_tenant() {}
|
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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/documents",
|
path = "/api/documents",
|
||||||
@@ -587,6 +637,26 @@ mod doc {
|
|||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub(super) fn delete_webdav_token() {}
|
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)]
|
#[cfg(test)]
|
||||||
@@ -604,6 +674,11 @@ mod tests {
|
|||||||
pub mod schemas {
|
pub mod schemas {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
pub use crate::auth::passkeys::{
|
||||||
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|||||||
+144
-43
@@ -16,7 +16,13 @@ use sha2::{Digest, Sha256};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{password, AuthenticatedUser},
|
auth::{
|
||||||
|
passkeys::{
|
||||||
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
|
},
|
||||||
|
password, AuthenticatedUser,
|
||||||
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||||
schema::{
|
schema::{
|
||||||
@@ -141,54 +147,13 @@ pub async fn login(
|
|||||||
return Err(AppError::unauthorized());
|
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
|
let preferred_slug = payload
|
||||||
.preferred_tenant_slug
|
.preferred_tenant_slug
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|slug| slug.trim().to_string())
|
.map(|slug| slug.trim().to_string())
|
||||||
.filter(|slug| !slug.is_empty());
|
.filter(|slug| !slug.is_empty());
|
||||||
|
|
||||||
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
|
complete_login(&state, &mut conn, &user, preferred_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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh(
|
pub async fn refresh(
|
||||||
@@ -367,6 +332,142 @@ pub async fn list_tenants(
|
|||||||
Ok(Json(TenantListResponse { 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(
|
fn issue_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/select-tenant", post(auth::select_tenant))
|
.route("/select-tenant", post(auth::select_tenant))
|
||||||
.route("/tenants", get(auth::list_tenants))
|
.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));
|
.route("/me", get(auth::me));
|
||||||
|
|
||||||
let documents_routes = Router::new()
|
let documents_routes = Router::new()
|
||||||
@@ -137,7 +147,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
"/webdav-tokens",
|
"/webdav-tokens",
|
||||||
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
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 protected_state = state.clone();
|
||||||
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
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 chrono::{DateTime, NaiveDateTime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
|
passkeys::PasskeySummary,
|
||||||
webdav_tokens::{
|
webdav_tokens::{
|
||||||
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
||||||
revoke_webdav_token as revoke_token,
|
revoke_webdav_token as revoke_token,
|
||||||
@@ -12,6 +17,7 @@ use crate::auth::{
|
|||||||
};
|
};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::WebdavToken;
|
use crate::models::WebdavToken;
|
||||||
|
use crate::state::AppState;
|
||||||
use crate::utils::{db::no_content, time::to_iso};
|
use crate::utils::{db::no_content, time::to_iso};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -37,6 +43,27 @@ pub struct CreateWebdavTokenRequest {
|
|||||||
pub expires_at: Option<String>,
|
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(
|
pub async fn list_webdav_tokens(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
@@ -90,6 +117,23 @@ pub async fn delete_webdav_token(
|
|||||||
no_content()
|
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 {
|
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
||||||
WebdavTokenResponse {
|
WebdavTokenResponse {
|
||||||
id: token.id,
|
id: token.id,
|
||||||
|
|||||||
+36
-1
@@ -175,12 +175,31 @@ diesel::table! {
|
|||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
user_id -> Uuid,
|
user_id -> Uuid,
|
||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
role -> Text,
|
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
updated_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! {
|
diesel::table! {
|
||||||
users (id) {
|
users (id) {
|
||||||
id -> Uuid,
|
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! {
|
diesel::table! {
|
||||||
webdav_tokens (id) {
|
webdav_tokens (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
@@ -231,6 +262,8 @@ diesel::joinable!(refresh_tokens -> users (user_id));
|
|||||||
diesel::joinable!(tags -> tenants (tenant_id));
|
diesel::joinable!(tags -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> users (user_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 -> tenants (tenant_id));
|
||||||
diesel::joinable!(webdav_tokens -> users (user_id));
|
diesel::joinable!(webdav_tokens -> users (user_id));
|
||||||
|
|
||||||
@@ -248,6 +281,8 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
tags,
|
tags,
|
||||||
tenants,
|
tenants,
|
||||||
user_memberships,
|
user_memberships,
|
||||||
|
user_passkeys,
|
||||||
users,
|
users,
|
||||||
|
webauthn_challenges,
|
||||||
webdav_tokens,
|
webdav_tokens,
|
||||||
);
|
);
|
||||||
|
|||||||
+11
-1
@@ -7,7 +7,7 @@ use diesel::{
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::jwt::JwtService,
|
auth::{jwt::JwtService, passkeys::PasskeyService},
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::PgPool,
|
db::PgPool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
@@ -24,6 +24,7 @@ pub struct AppState {
|
|||||||
storage: Arc<dyn ObjectStorage>,
|
storage: Arc<dyn ObjectStorage>,
|
||||||
pub jwt: JwtService,
|
pub jwt: JwtService,
|
||||||
pub tenants: TenantService,
|
pub tenants: TenantService,
|
||||||
|
pub passkeys: Option<PasskeyService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -52,12 +53,21 @@ impl AppState {
|
|||||||
let config = Arc::new(config);
|
let config = Arc::new(config);
|
||||||
let tenants = TenantService::new(pool.clone());
|
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 {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
config,
|
config,
|
||||||
storage,
|
storage,
|
||||||
jwt,
|
jwt,
|
||||||
tenants,
|
tenants,
|
||||||
|
passkeys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ impl JobHandler for ProvisionTenantJob {
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: member,
|
user_id: member,
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
role: "admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = diesel::insert_into(user_memberships::table)
|
if let Err(err) = diesel::insert_into(user_memberships::table)
|
||||||
|
|||||||
+232
-3
@@ -3,14 +3,23 @@ mod common;
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||||
|
use backend::auth::passkeys::{
|
||||||
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||||
|
RegistrationChallengeResponse,
|
||||||
|
};
|
||||||
use backend::jobs::JOB_PROVISION_TENANT;
|
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 backend::schema::{jobs, tenants, user_memberships, users};
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use webauthn_rs_core::proto::{
|
||||||
|
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
|
||||||
|
RegisterPublicKeyCredential,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthenticatedUser {
|
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?;
|
let me = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
||||||
assert_eq!(me.status(), StatusCode::OK);
|
assert_eq!(me.status(), StatusCode::OK);
|
||||||
|
|
||||||
app.with_conn(|conn| {
|
app.with_conn(move |conn| {
|
||||||
let user: backend::models::User = users::table
|
let user: backend::models::User = users::table
|
||||||
.filter(users::username.eq("signup-user"))
|
.filter(users::username.eq("signup-user"))
|
||||||
.first(conn)?;
|
.first(conn)?;
|
||||||
@@ -153,6 +162,227 @@ async fn signup_creates_user_tenant_and_membership() -> Result<()> {
|
|||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn login_rejects_invalid_password() -> Result<()> {
|
async fn login_rejects_invalid_password() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
@@ -278,7 +508,6 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id,
|
user_id,
|
||||||
tenant_id: secondary_id,
|
tenant_id: secondary_id,
|
||||||
role: "admin".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(user_memberships::table)
|
diesel::insert_into(user_memberships::table)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use axum::Router;
|
|||||||
use backend::auth::jwt::JwtService;
|
use backend::auth::jwt::JwtService;
|
||||||
use backend::config::AppConfig;
|
use backend::config::AppConfig;
|
||||||
use backend::db::{self, PgPool};
|
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::routes;
|
||||||
use backend::state::AppState;
|
use backend::state::AppState;
|
||||||
use backend::storage::ObjectStorage;
|
use backend::storage::ObjectStorage;
|
||||||
@@ -24,7 +24,7 @@ use http_body_util::BodyExt;
|
|||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json;
|
use serde_json::{self, json};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -142,6 +142,9 @@ impl TestApp {
|
|||||||
quickwit_index: None,
|
quickwit_index: None,
|
||||||
default_tenant_slug: "admin".to_string(),
|
default_tenant_slug: "admin".to_string(),
|
||||||
worker_max_document_bytes: 200 * 1024 * 1024,
|
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)?;
|
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))
|
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 username = username.to_string();
|
||||||
let password = password.to_string();
|
let password = password.to_string();
|
||||||
let role = role.to_string();
|
|
||||||
let tenant_id = self
|
let tenant_id = self
|
||||||
.state
|
.state
|
||||||
.tenants
|
.tenants
|
||||||
@@ -224,7 +226,6 @@ impl TestApp {
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
role,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(backend::schema::user_memberships::table)
|
diesel::insert_into(backend::schema::user_memberships::table)
|
||||||
@@ -236,6 +237,32 @@ impl TestApp {
|
|||||||
.await
|
.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> {
|
async fn ensure_default_tenant(&self) -> Result<Uuid> {
|
||||||
let slug_value = self.state.config.default_tenant_slug.clone();
|
let slug_value = self.state.config.default_tenant_slug.clone();
|
||||||
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
|
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(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user_b_id,
|
user_id: user_b_id,
|
||||||
tenant_id: tenant_b_id,
|
tenant_id: tenant_b_id,
|
||||||
role: "admin".to_string(),
|
|
||||||
};
|
};
|
||||||
diesel::insert_into(memberships_dsl::user_memberships)
|
diesel::insert_into(memberships_dsl::user_memberships)
|
||||||
.values(&membership)
|
.values(&membership)
|
||||||
|
|||||||
Reference in New Issue
Block a user