passkeys, self-service password, either/both sign-in requirement
Settings → Security lets a user change or remove their password, register and remove passkeys, and pick which of the two signs them in. "Either" takes one alone; "both" takes the password and a passkey in either order. Removing the password once a passkey exists leaves a passkey-only account. Nothing here asks for the current password, because a passkey-only account has none to give. The session is the gate instead, and every change drops the account's other sessions and forgets its cached WebDAV credentials. POST /api/auth/login gains an optional state_id, so the passkey leg can come first: a verified assertion parks the account under a handle and the password route finishes it. The relying party is built per request from Host, or from :authority on HTTP/2, which hyper does not mirror into a header. Registration asks for a discoverable credential. start_passkey_registration hardcodes require_resident_key(false), which sends residentKey "discouraged", and password managers obey it. Non-discoverable credentials still work and are marked in the list. A named challenge never says whether the name exists. An unknown name gets a real ceremony with an invented credential list, derived from a per-install secret and folded to lower case because users.name is COLLATE NOCASE. The list is always PASSKEY_LIMIT entries, which is also the cap per account — padding only hides a count while no account can exceed it. Finishing a padded ceremony verifies and then fails, or answering one with any passkey would sign its owner in and give the answer away. commit_if_reachable re-reads the password, the passkey count and the mode inside the writing transaction and rolls back if the account would have no way in. Setting a password as an admin now also deletes that user's passkeys and puts them back on "either", so a reset cannot leave a second factor they no longer have. An account that requires both factors cannot use WebDAV: HTTP Basic carries a password and nothing else. Schema v10: users.auth_mode, users.webauthn_id, a passkeys table. A removed password is an empty pass_hash, since SQLite cannot drop NOT NULL without rebuilding the table; verify_password substitutes a dummy hash so the timing does not reveal a passwordless account. webauthn-rs links OpenSSL, so the static musl build needs openssl-dev, openssl-libs-static and pkgconfig. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
M.hearthforge-ci.toml
@@ -54,7 +54,10 @@ to = "/usr/local/bin"
name = "setup"
timeout = 900
run_sh = """
apk add --no-cache musl-dev binaryen just curl libstdc++ podman-remote
# openssl-dev + openssl-libs-static: webauthn-rs links OpenSSL, and this is
# a static musl build. Mirrors the Containerfile's build stage.
apk add --no-cache musl-dev binaryen just curl libstdc++ podman-remote \
openssl-dev openssl-libs-static pkgconfig
# The official rust images use rustup's minimal profile, so rustfmt and
# clippy are absent. `just lint` needs both.
MCargo.lock
@@ -113,6 +113,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.119",
"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.119",
]
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -241,6 +280,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
@@ -253,6 +298,17 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "base64urlsafedata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c"
dependencies = [
"base64 0.21.7",
"pastey",
"serde",
]
[[package]]
name = "bitflags"
version = "2.13.1"
@@ -600,6 +656,12 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dav-server"
version = "0.11.0"
@@ -628,6 +690,26 @@ dependencies = [
"xmltree",
]
[[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.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive-where"
version = "1.6.1"
@@ -849,6 +931,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"
@@ -967,6 +1064,18 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -976,7 +1085,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasm-bindgen",
]
@@ -1181,7 +1290,7 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"headers-core",
"http",
@@ -1205,6 +1314,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "html-escape"
version = "0.2.15"
@@ -1694,7 +1809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae"
dependencies = [
"any_spawner",
"base64",
"base64 0.22.1",
"codee",
"futures",
"hydration_context",
@@ -1827,6 +1942,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1893,6 +2014,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1902,6 +2033,31 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1921,6 +2077,15 @@ dependencies = [
"thiserror 2.0.20",
]
[[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.4"
@@ -1933,6 +2098,43 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"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.119",
]
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "or_poisoned"
version = "0.1.0"
@@ -1952,7 +2154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"rand_core 0.6.4",
"subtle",
]
@@ -1962,6 +2164,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -2028,6 +2236,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -2145,6 +2359,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -2158,8 +2378,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -2169,7 +2399,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -2181,6 +2421,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "reactive_graph"
version = "0.2.14"
@@ -2343,6 +2592,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.4"
@@ -2402,6 +2660,16 @@ dependencies = [
"serde_derive",
]
[[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.229"
@@ -2486,7 +2754,7 @@ dependencies = [
"api-types",
"argon2",
"axum",
"base64",
"base64 0.22.1",
"bytes",
"chrono",
"clap",
@@ -2502,7 +2770,7 @@ dependencies = [
"infer",
"mime_guess",
"multer",
"rand",
"rand 0.8.8",
"rusqlite",
"rust-embed",
"serde",
@@ -2517,6 +2785,9 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
"webauthn-rs",
"webauthn-rs-proto",
"webp",
"xmltree",
"zip",
@@ -2529,7 +2800,7 @@ version = "0.8.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"const-str",
"const_format",
@@ -2888,6 +3159,36 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@@ -3153,6 +3454,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
@@ -3169,12 +3471,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.0"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
]
@@ -3212,6 +3515,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
@@ -3329,6 +3641,74 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webauthn-attestation-ca"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e"
dependencies = [
"base64urlsafedata",
"openssl",
"openssl-sys",
"serde",
"tracing",
"uuid",
]
[[package]]
name = "webauthn-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422"
dependencies = [
"base64urlsafedata",
"serde",
"tracing",
"url",
"uuid",
"webauthn-rs-core",
]
[[package]]
name = "webauthn-rs-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"der-parser",
"hex",
"nom",
"openssl",
"openssl-sys",
"rand 0.9.5",
"rand_chacha 0.9.0",
"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.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"serde",
"serde_json",
"url",
]
[[package]]
name = "webp"
version = "0.3.1"
@@ -3431,12 +3811,35 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[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 = "xattr"
version = "1.6.1"
MContainerfile
@@ -11,7 +11,10 @@ ARG BUN_VERSION
ARG TRUNK_VERSION
# binaryen so trunk uses the system wasm-opt instead of downloading a glibc binary that cannot run on musl
# openssl-dev + openssl-libs-static for webauthn-rs, whose core crate links
# OpenSSL. The binary is static, so nothing is needed in the runtime image.
RUN apk add --no-cache curl ca-certificates musl-dev binaryen just \
openssl-dev openssl-libs-static pkgconfig \
&& rustup target add wasm32-unknown-unknown
# bun
MREADME.md
@@ -32,6 +32,10 @@ external services.
[WebDAV](#webdav).
- **UI**: light, dark, or system theme. English, German, and French.
Optional single-click open.
- **Passkeys**: sign in with a fingerprint, a face, or a security key.
Everyone manages their own under Settings → Security, and can require a
password *and* a passkey, or drop the password entirely. See
[Sign-in](#sign-in).
- **Security**: Argon2 password hashes, HttpOnly session cookies with a
30-day lifetime, growing delay on failed logins per user name.
@@ -127,13 +131,44 @@ working credentials: every row's copy button produces the live link. Holding a
share token is access, so treat that view (and the tokens copied from it)
accordingly.
## Sign-in
Every account starts with a password. Settings → Security adds passkeys and
decides how the two combine.
| Requirement | What signs you in |
|-------------|-------------------|
| Password or passkey | Either one alone. This is the default. |
| Password and passkey | Both, in either order. |
Once a passkey exists the password can go, leaving a passkey-only account. No
change here asks for the current password, because a passkey-only account has
none; the session is the gate, and every change signs the account out
everywhere else.
The login button asks the browser for any passkey it holds for this site, so
no user name is needed. Older security keys cannot do that and need the name
typed in first; the settings list marks those with "needs your user name".
Passkeys need a domain name. Set `--public-url` behind a proxy that rewrites
`Host`, and note that a bare IP address will not work at all. Password sign-in
still works on such a host, but an account requiring both factors does not.
> [!NOTE]
> An account that requires both factors cannot use WebDAV, because HTTP Basic
> carries a password and nothing else.
Admins recover a locked-out account by setting a new password for it. That
also deletes the account's passkeys and drops any two-factor requirement, so
the new password is the one way back in.
## WebDAV
Two mount points. Your permissions are the same as in the web UI.
| URL | Contents | Credentials |
|-----|----------|-------------|
| `https://host/dav` | Every root folder the user has, one collection each | Account name and password |
| `https://host/dav` | Every root folder the user has, one collection each | Account name and password (see [Sign-in](#sign-in)) |
| `https://host/dav-share/<token>` | One public share | None, or the share password |
Mount it with the file manager you already have:
@@ -161,8 +196,9 @@ Notes:
## Development
Requirements: Rust 1.90 or newer with the `wasm32-unknown-unknown` target,
[trunk](https://trunk.rs) 0.21, [bun](https://bun.sh), and
[just](https://github.com/casey/just).
[trunk](https://trunk.rs) 0.21, [bun](https://bun.sh),
[just](https://github.com/casey/just), and OpenSSL headers (`webauthn-rs`
links it; on Alpine that is `openssl-dev` plus `openssl-libs-static`).
```bash
rustup target add wasm32-unknown-unknown
@@ -196,6 +232,12 @@ build artifacts and not committed.
Tests live in `server/tests` and run against an in-process server with a
temporary root. CI runs on Hearthforge, see `.hearthforge-ci.toml`.
No test drives a real authenticator, so nothing here verifies a WebAuthn
signature — that is `webauthn-rs`' own job. `server/tests/api_passkeys.rs`
covers everything around it: which credential combinations the server
accepts, which it refuses, and that a half-finished sign-in never becomes a
session.
## License
[AGPL-3.0-or-later](LICENSE.md).
Mapi-types/src/lib.rs
@@ -14,6 +14,23 @@ pub const AUTH_LOGIN: &str = "/api/auth/login";
pub const AUTH_LOGOUT: &str = "/api/auth/logout";
pub const AUTH_ME: &str = "/api/auth/me";
pub const AUTH_SETUP: &str = "/api/auth/setup";
/// Change or set the signed-in user's password (`POST`), or remove it
/// (`DELETE`, passkey-only accounts).
pub const AUTH_PASSWORD: &str = "/api/auth/password";
/// `PUT` the signed-in user's sign-in requirement ([`AuthMode`]).
pub const AUTH_MODE: &str = "/api/auth/mode";
/// The signed-in user's passkeys: `GET {AUTH_PASSKEYS}` lists them,
/// `DELETE {AUTH_PASSKEYS}/{id}` removes one.
pub const AUTH_PASSKEYS: &str = "/api/auth/passkeys";
/// Start registering a new passkey (`POST`). Finished at
/// `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
pub const AUTH_PASSKEYS_REGISTER: &str = "/api/auth/passkeys/register";
/// Start a passkey sign-in (`POST`, no session needed). Finished at
/// `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
pub const AUTH_PASSKEY_LOGIN: &str = "/api/auth/passkey/login";
/// Second leg of both WebAuthn ceremonies: the browser's answer goes to the
/// begin path plus this suffix.
pub const FINISH_SUFFIX: &str = "/finish";
/// File operations: `{FILES}/{root_id}` and `{FILES}/{root_id}/{path...}`.
pub const FILES: &str = "/api/files";
/// Share management (authenticated): `{SHARES}` and `{SHARES}/{id}`.
@@ -178,6 +195,9 @@ pub struct CreateUser {
#[derive(Serialize, Deserialize)]
pub struct UpdateUser {
/// Setting one is also the recovery path for a locked-out account: it
/// deletes every passkey and puts the account back on
/// [`AuthMode::Either`], leaving the new password as the one way in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -325,6 +345,16 @@ pub struct UserInfo {
/// None for the root picker.
#[serde(default)]
pub default_root_id: Option<i64>,
/// What this account needs to sign in.
#[serde(default)]
pub auth_mode: AuthMode,
/// Whether a password is set at all. False means passkeys only.
#[serde(default = "yes")]
pub has_password: bool,
}
fn yes() -> bool {
true
}
#[derive(Serialize, Deserialize, Clone)]
@@ -524,6 +554,8 @@ mod tests {
thumbnails: true,
language: None,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
}),
roots: vec![RootInfo {
id: 1,
@@ -540,3 +572,157 @@ mod tests {
assert_eq!(back.roots.len(), 1);
}
}
// ---------------------------------------------------------------------------
// Sign-in methods: password, passkeys, and how they combine
// ---------------------------------------------------------------------------
/// What an account needs to sign in.
///
/// Not a "2FA on/off" flag: [`AuthMode::Either`] with no password is a
/// passkey-only account, which is still two factors when the authenticator
/// does user verification (the server always asks for it).
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AuthMode {
/// Password *or* passkey. Either one alone signs the user in.
#[default]
Either,
/// Password *and* passkey. Both legs must pass, in either order.
Both,
}
impl AuthMode {
pub fn as_str(self) -> &'static str {
match self {
AuthMode::Either => "either",
AuthMode::Both => "both",
}
}
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"either" => Some(AuthMode::Either),
"both" => Some(AuthMode::Both),
_ => None,
}
}
}
/// One registered passkey, as shown in profile settings. Never carries key
/// material.
#[derive(Serialize, Deserialize, Clone)]
pub struct PasskeyInfo {
pub id: i64,
/// User-chosen label ("YubiKey", "Work laptop").
pub name: String,
/// RFC 3339 UTC.
pub created_at: String,
pub last_used_at: Option<String>,
/// Whether the browser reported this credential as discoverable, so it
/// can sign in without the account name. `None` when the browser did not
/// say — the `credProps` extension is optional and unsigned, so absence
/// means "unknown", never "no".
pub discoverable: Option<bool>,
}
/// `POST {AUTH_PASSWORD}` — set or change the password.
///
/// No current password to confirm: a passkey-only account has none to give.
/// The session is the gate, and the server drops the account's other
/// sessions on every change.
#[derive(Serialize, Deserialize)]
pub struct ChangePassword {
pub new_password: String,
}
/// `PUT {AUTH_MODE}`.
#[derive(Serialize, Deserialize)]
pub struct SetAuthMode {
pub mode: AuthMode,
}
/// A WebAuthn challenge on its way to the browser.
///
/// `options` is the raw JSON the browser's `parseCreationOptionsFromJSON` /
/// `parseRequestOptionsFromJSON` expects, carried as a string rather than a
/// nested object. Neither side has to re-parse it: the server serializes the
/// `webauthn-rs` type straight into it, and the client hands it to
/// `JSON.parse` in the browser shim.
#[derive(Serialize, Deserialize)]
pub struct PasskeyChallenge {
/// Opaque handle for the server-side ceremony state. Echoed back on
/// finish. Not a credential, and useless on its own.
pub state_id: String,
pub options: String,
}
/// `POST {AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
#[derive(Serialize, Deserialize)]
pub struct PasskeyRegisterFinish {
pub state_id: String,
/// Label for the new passkey.
pub name: String,
/// The browser's `PublicKeyCredential.toJSON()` output, verbatim.
pub credential: String,
}
/// `POST {AUTH_PASSKEY_LOGIN}` — begin a passkey sign-in.
#[derive(Serialize, Deserialize, Default)]
pub struct PasskeyLoginBegin {
/// Account name, when the user typed one. Without it the server issues a
/// discoverable challenge, which only finds passkeys the authenticator
/// stores itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Ask for a conditional-mediation (autofill) challenge instead of a
/// modal one.
#[serde(default)]
pub conditional: bool,
}
/// `POST {AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
#[derive(Serialize, Deserialize)]
pub struct PasskeyLoginFinish {
pub state_id: String,
pub credential: String,
}
/// `POST {AUTH_LOGIN}` — the password leg of a sign-in.
#[derive(Serialize, Deserialize)]
pub struct LoginReq {
/// Omitted only when `state_id` names a half-finished sign-in, which
/// already knows who the user is.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub password: String,
/// Handle from a passkey leg that still needs a password (an
/// [`AuthMode::Both`] account signing in passkey-first).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_id: Option<String>,
}
/// The answer to either sign-in leg.
///
/// Exactly one of the three shapes: signed in, needs a passkey next, or needs
/// a password next. The two "needs" cases are how [`AuthMode::Both`] works,
/// and which one appears depends only on which leg the user started with.
#[derive(Serialize, Deserialize, Default)]
pub struct LoginResp {
/// True when the session cookie is set and the user is in.
pub ok: bool,
/// Present when this leg passed but a passkey is still required.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub passkey_challenge: Option<PasskeyChallenge>,
/// Present when this leg passed but the password is still required.
/// Carries the account name, so the form can show whose password it
/// wants, and the handle to send back with it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_required: Option<PasswordStep>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct PasswordStep {
pub name: String,
pub state_id: String,
}
Mserver/Cargo.toml
@@ -73,6 +73,13 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ignore = "0.4"
grep = "0.4"
webauthn-rs = { version = "0.5.5", features = ["conditional-ui"], default-features = false }
uuid = { version = "1.26.1", features = ["v4"] }
# For `ResidentKeyRequirement` and `AllowCredentials`, which `webauthn-rs` uses
# internally but does not re-export. Keep this version equal to webauthn-rs'
# own: it already pulls this crate in, and two different versions would compile
# while being different types.
webauthn-rs-proto = "0.5.5"
[dev-dependencies]
base64 = "0.22"
Mserver/src/api/auth.rs
@@ -1,9 +1,9 @@
use std::sync::Arc;
use api_types::{Credentials, Me, OkResp, RootInfo, UserInfo};
use api_types::{AuthMode, Credentials, LoginReq, LoginResp, Me, OkResp, RootInfo, UserInfo};
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::http::{HeaderMap, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
@@ -65,6 +65,8 @@ async fn me_for(state: &AppState, user: &User, roots: Vec<RootRow>) -> Result<Me
default_root_id: user
.default_root_id
.filter(|id| roots.iter().any(|r| r.id == *id)),
auth_mode: user.auth_mode,
has_password: user.has_password,
}),
roots,
allow_writable_shares: state.db.allow_writable_shares().await?,
@@ -197,35 +199,117 @@ pub async fn setup(
Ok(res)
}
/// POST /api/auth/login
/// POST /api/auth/login — the password leg of signing in.
///
/// A correct password signs in, unless the account also requires a passkey:
/// then a challenge comes back instead of a session. A wrong password gets
/// the same error either way.
///
/// `state_id` is the other order. A passkey sign-in that landed on an account
/// requiring both legs parks the identified user under that handle, so this
/// route already knows who is asking and only needs the password.
pub async fn login(
State(state): State<Arc<AppState>>,
Json(body): Json<Credentials>,
uri: Uri,
headers: HeaderMap,
Json(body): Json<LoginReq>,
) -> Result<Response, ApiError> {
let name = match &body.state_id {
Some(state_id) => {
let Some(crate::webauthn::Pending::NeedsPassword { user_id }) =
crate::webauthn::take(state_id)
else {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"that took too long, please try again",
"err_challenge_expired",
));
};
match state.db.find_user_by_id(user_id).await? {
Some(u) => u.name,
None => return Err(invalid_credentials()),
}
}
None => match body.name.as_deref().map(str::trim) {
Some(n) if !n.is_empty() => n.to_string(),
_ => return Err(invalid_credentials()),
},
};
// Online guessing gets slower per failed attempt on this name.
let delay = auth::login_delay(&body.name);
let delay = auth::login_delay(&name);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
let verified = state.db.verify_password(&body.name, &body.password).await?;
auth::record_login(&body.name, verified.is_some());
let verified = state.db.verify_password(&name, &body.password).await?;
auth::record_login(&name, verified.is_some());
let Some(user) = verified else {
return Err(ApiError::localized(
StatusCode::UNAUTHORIZED,
"invalid name or password",
"err_invalid_credentials",
));
return Err(invalid_credentials());
};
let token = auth::random_token();
state.db.create_session(user.id, &token).await?;
// A passkey is also required, and this request did not come from one.
if user.auth_mode == AuthMode::Both && body.state_id.is_none() {
return second_factor(&state, &user, &uri, &headers).await;
}
crate::api::passkeys::sign_in(&state, user.id).await
}
let mut res = Json(OkResp {}).into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
);
Ok(res)
fn invalid_credentials() -> ApiError {
ApiError::localized(
StatusCode::UNAUTHORIZED,
"invalid name or password",
"err_invalid_credentials",
)
}
/// The password passed; ask for the passkey that must follow it.
///
/// The relying party is built here rather than taken as an extractor. It needs
/// a domain name, and most sign-ins do not need it at all — an extractor on
/// `login` would fail every sign-in on a server reached by bare IP.
async fn second_factor(
state: &AppState,
user: &crate::db::User,
uri: &Uri,
headers: &HeaderMap,
) -> Result<Response, ApiError> {
let rp = crate::webauthn::relying_party(state, uri, headers)?;
let keys: Vec<webauthn_rs::prelude::Passkey> =
crate::api::passkeys::load_passkeys(state, user.id)
.await?
.into_iter()
.map(|(_, k)| k)
.collect();
// Only reachable if every stored passkey became unreadable: the mode
// cannot be set without one, and the last one cannot be deleted under it.
if keys.is_empty() {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"this account requires a passkey but has none",
));
}
let (options, auth) = rp.start_passkey_authentication(&keys).map_err(|e| {
tracing::warn!(error = ?e, "cannot start the second factor");
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"that passkey could not be used",
"err_passkey_failed",
)
})?;
let challenge = crate::api::passkeys::challenge(
crate::webauthn::Pending::Authenticate {
user_id: user.id,
state: Box::new(auth),
second_factor: true,
},
&options,
)?;
Ok(Json(LoginResp {
ok: false,
passkey_challenge: Some(challenge),
..Default::default()
})
.into_response())
}
/// POST /api/auth/logout
Mserver/src/api/dav.rs
@@ -21,7 +21,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex, Weak};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use api_types::{DAV, DAV_SHARE, Mode};
use api_types::{AuthMode, DAV, DAV_SHARE, Mode};
use axum::body::Body;
use axum::extract::State;
use axum::http::header::{HeaderMap, WWW_AUTHENTICATE};
@@ -280,9 +280,18 @@ async fn authenticate(state: &AppState, headers: &HeaderMap) -> Option<(String,
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
let user = state.db.verify_password(&name, &password).await.ok()?;
auth::record_login(&name, user.is_some());
user.map(|u| u.id)
let verified = state.db.verify_password(&name, &password).await.ok()?;
// Record what the password did, not what the rule below decides.
// A mount on an account that requires a passkey keeps retrying,
// and counting each retry as a failed guess would pin that name's
// delay and lock the person out of the web login too.
auth::record_login(&name, verified.is_some());
// Basic carries a password and nothing else, so an account that
// requires a passkey too cannot be authenticated over WebDAV.
// Accepting it would quietly downgrade the account's own setting.
verified
.filter(|u| u.auth_mode == AuthMode::Either)
.map(|u| u.id)
}
})
.await?;
Mserver/src/api/mod.rs
@@ -1,8 +1,9 @@
use std::sync::Arc;
use api_types::{
ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, DAV,
DAV_SHARE, FILES, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_MODE,
AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD, AUTH_SETUP, DAV,
DAV_SHARE, FILES, FINISH_SUFFIX, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -86,9 +87,10 @@ pub(crate) fn is_scriptable_mime(mime: &str) -> bool {
mod admin;
mod auth;
mod common;
pub(crate) mod common;
mod dav;
mod files;
mod passkeys;
mod search;
mod shares;
mod spa;
@@ -102,6 +104,9 @@ pub fn router(state: Arc<AppState>) -> Router {
let share_token = format!("{SHARE}/{{token}}");
let share_unlock = format!("{SHARE}/{{token}}{SHARE_UNLOCK_SUFFIX}");
let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
let passkey_id = format!("{AUTH_PASSKEYS}/{{id}}");
let passkey_register_finish = format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}");
let passkey_login_finish = format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}");
let admin_share_id = format!("{ADMIN_SHARES}/{{id}}");
// A wildcard needs something to capture, so `/dav/` gets its own pattern:
// mount clients ask for it with the trailing slash, which matches neither
@@ -115,6 +120,19 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(AUTH_LOGOUT, post(auth::logout))
.route(AUTH_ME, get(auth::me).put(auth::update_profile))
.route(AUTH_SETUP, post(auth::setup))
.route(
AUTH_PASSWORD,
post(passkeys::change_password).delete(passkeys::delete_password),
)
.route(AUTH_MODE, put(passkeys::set_mode))
// axum matches a literal segment before a parameter, so `/register`
// and `{id}` can both live under this path.
.route(AUTH_PASSKEYS, get(passkeys::list))
.route(AUTH_PASSKEYS_REGISTER, post(passkeys::register_begin))
.route(&passkey_register_finish, post(passkeys::register_finish))
.route(&passkey_id, delete(passkeys::delete))
.route(AUTH_PASSKEY_LOGIN, post(passkeys::login_begin))
.route(&passkey_login_finish, post(passkeys::login_finish))
.route(SEARCH, get(search::search))
.route(&files_root, get(files::list_root).put(files::file_put_root))
.route(&files_item, get(files::file_get))
Aserver/src/api/passkeys.rs
@@ -0,0 +1,719 @@
//! Self-service credentials: the password, passkeys, and which of the two an
//! account needs to sign in.
//!
//! Every route here except the two `login_*` ones needs a session. The two
//! that do not are the passkey half of signing in, which by definition runs
//! before there is one.
use std::sync::Arc;
use api_types::{
AuthMode, ChangePassword, LoginResp, OkResp, PasskeyChallenge, PasskeyInfo, PasskeyLoginBegin,
PasskeyLoginFinish, PasskeyRegisterFinish, PasswordStep, SetAuthMode,
};
use axum::Json;
use axum::extract::{Path as AxumPath, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use webauthn_rs::prelude::*;
use webauthn_rs_proto::{AllowCredentials, ResidentKeyRequirement};
use crate::api::common::{SessionUser, hash_password, validate_password};
use crate::auth::{self, parse_session_cookie, session_cookie};
use crate::db::{PASSKEY_LIMIT, PasskeyDeleted, PasskeyRow};
use crate::error::{ApiError, AppState};
use crate::webauthn::{Pending, Rp};
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
fn challenge_expired() -> ApiError {
ApiError::localized(
StatusCode::BAD_REQUEST,
"that took too long, please try again",
"err_challenge_expired",
)
}
/// One message for every way a WebAuthn ceremony can fail.
///
/// The detail goes to the log, never to the client: a bad signature, a
/// mismatched origin and an unknown credential are all "it did not work" to
/// the person at the keyboard, and telling them apart only helps an attacker.
fn webauthn_failed(e: WebauthnError) -> ApiError {
tracing::warn!(error = ?e, "webauthn ceremony failed");
ApiError::localized(
StatusCode::UNAUTHORIZED,
"that passkey could not be used",
"err_passkey_failed",
)
}
/// The database refused a change that would have left the account with no way
/// to sign in.
///
/// Each handler checks its own rule first and says which one, so this is only
/// reached when two changes race: a count taken before the write was already
/// stale. Rare enough that one message covers it.
fn locked_out() -> ApiError {
ApiError::localized(
StatusCode::BAD_REQUEST,
"that would leave the account with no way to sign in",
"err_locked_out",
)
}
fn too_many_passkeys() -> ApiError {
ApiError::localized(
StatusCode::BAD_REQUEST,
"this account already holds as many passkeys as it may",
"err_passkey_limit",
)
}
fn bad_credential() -> ApiError {
ApiError::localized(
StatusCode::BAD_REQUEST,
"the browser sent an unreadable credential",
"err_passkey_malformed",
)
}
// ---------------------------------------------------------------------------
// Shared checks
// ---------------------------------------------------------------------------
/// Apply the fallout of any credential change: every other session of this
/// user is dropped, and cached WebDAV credentials are forgotten.
///
/// This carries more weight than it looks. Nothing on these routes asks for
/// the current password — a passkey-only account has none — so the session is
/// the only thing standing behind a credential change. Dropping the others
/// keeps a session stolen before the change from outliving it.
async fn invalidate_elsewhere(
state: &AppState,
user_id: i64,
headers: &HeaderMap,
) -> Result<(), ApiError> {
let current = parse_session_cookie(headers).unwrap_or_default();
state.db.delete_other_sessions(user_id, ¤t).await?;
auth::forget_verified_for(user_id);
Ok(())
}
fn passkey_name(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return "Passkey".to_string();
}
trimmed.chars().take(64).collect()
}
fn info(row: &PasskeyRow) -> PasskeyInfo {
PasskeyInfo {
id: row.id,
name: row.name.clone(),
created_at: row.created_at.clone(),
last_used_at: row.last_used_at.clone(),
discoverable: row.discoverable,
}
}
/// The stored credentials of one account, ready for `webauthn-rs`.
///
/// A row that will not deserialize is skipped rather than fatal. It can only
/// come from a `webauthn-rs` format change, and one unreadable passkey must
/// not lock an account out of the others.
pub(crate) async fn load_passkeys(
state: &AppState,
user_id: i64,
) -> Result<Vec<(i64, Passkey)>, ApiError> {
Ok(state
.db
.user_passkeys(user_id)
.await?
.into_iter()
.filter_map(|r| match serde_json::from_str::<Passkey>(&r.passkey) {
Ok(k) => Some((r.id, k)),
Err(e) => {
tracing::error!(passkey_id = r.id, error = %e, "stored passkey is unreadable");
None
}
})
.collect())
}
// ---------------------------------------------------------------------------
// Password
// ---------------------------------------------------------------------------
/// POST `{AUTH_PASSWORD}` — set or change the password.
pub async fn change_password(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
headers: HeaderMap,
Json(body): Json<ChangePassword>,
) -> Result<Json<OkResp>, ApiError> {
validate_password(&body.new_password)?;
let hash = hash_password(&body.new_password).await?;
state
.db
.set_password_keeping_sessions(user.id, &hash)
.await?;
invalidate_elsewhere(&state, user.id, &headers).await?;
Ok(Json(OkResp {}))
}
/// DELETE `{AUTH_PASSWORD}` — leave the account on passkeys alone.
pub async fn delete_password(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
headers: HeaderMap,
) -> Result<Json<OkResp>, ApiError> {
if !user.has_password {
return Ok(Json(OkResp {}));
}
// The account must keep at least one way in, and `Both` needs a password
// by definition.
if state.db.count_passkeys(user.id).await? == 0 {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"add a passkey before removing your password",
"err_password_last_credential",
));
}
if user.auth_mode == AuthMode::Both {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"this account requires a password and a passkey",
"err_required_by_mode",
));
}
if !state.db.clear_user_password(user.id).await? {
return Err(locked_out());
}
invalidate_elsewhere(&state, user.id, &headers).await?;
Ok(Json(OkResp {}))
}
// ---------------------------------------------------------------------------
// Sign-in requirement
// ---------------------------------------------------------------------------
/// PUT `{AUTH_MODE}`.
pub async fn set_mode(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
headers: HeaderMap,
Json(body): Json<SetAuthMode>,
) -> Result<Json<OkResp>, ApiError> {
if body.mode == AuthMode::Both {
if !user.has_password {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"set a password before requiring both",
"err_mode_needs_password",
));
}
if state.db.count_passkeys(user.id).await? == 0 {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"add a passkey before requiring both",
"err_mode_needs_passkey",
));
}
}
if !state.db.set_user_auth_mode(user.id, body.mode).await? {
return Err(locked_out());
}
// WebDAV speaks HTTP Basic, which carries a password and nothing else. An
// account that requires both can no longer authenticate a mount, so any
// cached Basic credential has to go.
invalidate_elsewhere(&state, user.id, &headers).await?;
Ok(Json(OkResp {}))
}
// ---------------------------------------------------------------------------
// Managing passkeys
// ---------------------------------------------------------------------------
/// GET `{AUTH_PASSKEYS}`.
pub async fn list(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
) -> Result<Json<Vec<PasskeyInfo>>, ApiError> {
let rows = state.db.user_passkeys(user.id).await?;
Ok(Json(rows.iter().map(info).collect()))
}
/// DELETE `{AUTH_PASSKEYS}/{id}`.
pub async fn delete(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
headers: HeaderMap,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<OkResp>, ApiError> {
// The database decides, inside one transaction, whether the account would
// still have a way in. Asking it first means an id that does not exist is
// a plain 404, not a complaint about a rule it never reached.
match state.db.delete_passkey(id, user.id).await? {
PasskeyDeleted::Gone => {}
PasskeyDeleted::NotFound => {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
"no such passkey",
"err_passkey_not_found",
));
}
// Say which of the two rules stopped it.
PasskeyDeleted::LastCredential if user.auth_mode == AuthMode::Both => {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"this account requires a password and a passkey",
"err_required_by_mode",
));
}
PasskeyDeleted::LastCredential => {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"set a password before removing your last passkey",
"err_passkey_last_credential",
));
}
}
invalidate_elsewhere(&state, user.id, &headers).await?;
Ok(Json(OkResp {}))
}
/// POST `{AUTH_PASSKEYS_REGISTER}` — first leg of registration.
pub async fn register_begin(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
Rp(rp): Rp,
) -> Result<Json<PasskeyChallenge>, ApiError> {
// Checked again inside `add_passkey`, which is where it actually holds.
// This one only spares the user a ceremony that could not be stored.
if state.db.count_passkeys(user.id).await? as usize >= PASSKEY_LIMIT {
return Err(too_many_passkeys());
}
let wid = state.db.user_webauthn_id(user.id).await?;
// Excluding what is already registered makes the authenticator refuse a
// second credential for this account, instead of silently creating one
// the user then has to tell apart from the first.
let existing: Vec<CredentialID> = load_passkeys(&state, user.id)
.await?
.iter()
.map(|(_, k)| k.cred_id().clone())
.collect();
let (mut options, reg) = rp
.start_passkey_registration(wid, &user.name, &user.name, Some(existing))
.map_err(webauthn_failed)?;
ask_for_discoverable(&mut options);
Ok(Json(challenge(
Pending::Register {
user_id: user.id,
state: Box::new(reg),
},
&options,
)?))
}
/// POST `{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}`.
pub async fn register_finish(
State(state): State<Arc<AppState>>,
SessionUser { user, .. }: SessionUser,
Rp(rp): Rp,
headers: HeaderMap,
Json(body): Json<PasskeyRegisterFinish>,
) -> Result<Json<PasskeyInfo>, ApiError> {
let Some(Pending::Register {
user_id,
state: reg,
}) = crate::webauthn::take(&body.state_id)
else {
return Err(challenge_expired());
};
// The handle is opaque and single-use, so this can only be a client that
// mixed two ceremonies up. Refuse rather than register to the wrong
// account.
if user_id != user.id {
return Err(challenge_expired());
}
let cred: RegisterPublicKeyCredential =
serde_json::from_str(&body.credential).map_err(|_| bad_credential())?;
// Whether the browser thinks it stored a discoverable credential. Unsigned
// and optional, so it is a UI hint only — never a security decision.
let discoverable = cred.extensions.cred_props.as_ref().and_then(|c| c.rk);
let passkey = rp
.finish_passkey_registration(&cred, ®)
.map_err(webauthn_failed)?;
let encoded = serde_json::to_string(&passkey).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("cannot store passkey: {e}"),
)
})?;
let Some(row) = state
.db
.add_passkey(
user.id,
passkey.cred_id().as_ref(),
&encoded,
&passkey_name(&body.name),
discoverable,
)
.await
.map_err(|e| match e {
// `passkeys.cred_id` is UNIQUE across the whole table, so this
// also fires when the credential belongs to another account.
rusqlite::Error::SqliteFailure(f, _)
if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
{
ApiError::localized(
StatusCode::CONFLICT,
"that passkey is already registered",
"err_passkey_duplicate",
)
}
other => other.into(),
})?
else {
return Err(too_many_passkeys());
};
invalidate_elsewhere(&state, user.id, &headers).await?;
Ok(Json(info(&row)))
}
// ---------------------------------------------------------------------------
// Signing in with a passkey
// ---------------------------------------------------------------------------
/// Key material for one decoy, in counter mode so any id length is reachable.
///
/// `tag` separates the two things derived per decoy, its length and its bytes,
/// so neither can be read off the other.
fn decoy_bytes(secret: &str, name: &str, index: u32, tag: u8, len: usize) -> Vec<u8> {
use sha2::{Digest, Sha256};
let mut out = Vec::with_capacity(len + 32);
let mut block = 0u32;
while out.len() < len {
let mut h = Sha256::new();
h.update(secret.as_bytes());
h.update([tag]);
// Length-prefixed, so two names cannot run together into one input.
h.update((name.len() as u64).to_le_bytes());
h.update(name.as_bytes());
h.update(index.to_le_bytes());
h.update(block.to_le_bytes());
out.extend_from_slice(&h.finalize());
block += 1;
}
out.truncate(len);
out
}
/// One fake `allowCredentials` entry, stable across requests.
///
/// A real account lists the same credential ids every time. A decoy derived
/// from a per-install secret does too, so probing one name twice gives an
/// attacker nothing to compare.
///
/// The name is ASCII-folded first, because `users.name` is `COLLATE NOCASE`.
/// Without that, "admin" and "ADMIN" would return the same real credential
/// with different decoys around it, and comparing the two spellings would say
/// which entries were real.
fn decoy(secret: &str, name: &str, index: u32, lengths: &[usize]) -> AllowCredentials {
let name = &name.to_ascii_lowercase();
let pick = decoy_bytes(secret, name, index, 1, 1);
let len = lengths[usize::from(pick[0]) % lengths.len()];
AllowCredentials {
type_: "public-key".to_string(),
id: decoy_bytes(secret, name, index, 0, len).into(),
transports: None,
}
}
/// POST `{AUTH_PASSKEY_LOGIN}` — first leg of a passkey sign-in.
///
/// An empty name gets a discoverable challenge, which any passkey the browser
/// holds for this site can answer. A name gets a challenge listing credentials,
/// which is the only form a non-discoverable credential can answer.
///
/// This route needs no session, so a named challenge must not say whether the
/// name exists. It does not: an unknown name gets a list of decoys, and the
/// two starters' other differences are flattened below. The account behind a
/// real name still decides nothing here, because the assertion has to verify
/// before anyone is signed in.
pub async fn login_begin(
State(state): State<Arc<AppState>>,
Rp(rp): Rp,
Json(body): Json<PasskeyLoginBegin>,
) -> Result<Json<PasskeyChallenge>, ApiError> {
// Autofill carries no name and its challenge is the same for everyone, so
// it needs none of the padding below.
let name = match body
.name
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
{
Some(name) if !body.conditional => name,
_ => {
let (mut options, disc) = rp
.start_discoverable_authentication()
.map_err(webauthn_failed)?;
// `start_discoverable_authentication` always asks for conditional
// mediation, which parks the request in the autofill dropdown. The
// button wants the modal picker instead.
if !body.conditional {
options.mediation = None;
}
return Ok(Json(challenge(
Pending::Discoverable {
state: Box::new(disc),
decoy: false,
},
&options,
)?));
}
};
let found = match state.db.find_user_by_name(name).await?.filter(|u| u.active) {
Some(u) => {
let keys: Vec<Passkey> = load_passkeys(&state, u.id)
.await?
.into_iter()
.map(|(_, k)| k)
.collect();
(!keys.is_empty()).then_some((u.id, keys))
}
None => None,
};
// An unknown name still gets a working ceremony, not a fake one: a passkey
// the browser holds for this site can answer it. Only the credential list
// is invented.
let (mut options, pending) = match found {
Some((user_id, keys)) => {
let (options, auth) = rp
.start_passkey_authentication(&keys)
.map_err(webauthn_failed)?;
(
options,
Pending::Authenticate {
user_id,
state: Box::new(auth),
second_factor: false,
},
)
}
None => {
let (options, disc) = rp
.start_discoverable_authentication()
.map_err(webauthn_failed)?;
(
options,
Pending::Discoverable {
state: Box::new(disc),
decoy: true,
},
)
}
};
// The two starters disagree on more than the credential list.
// `start_discoverable_authentication` asks for the `uvm` extension and
// conditional mediation; `start_passkey_authentication` asks for neither.
// Left alone those two fields would answer the question the decoys are
// here to hide. Neither is checked when the assertion comes back, so
// clearing them costs nothing.
options.public_key.extensions = None;
options.mediation = None;
// Transports vary per authenticator and a decoy has none to copy, so they
// come off the real entries too. They are a hint to the browser about
// where to look, never a requirement.
for cred in &mut options.public_key.allow_credentials {
cred.transports = None;
}
let secret = state.db.decoy_secret().await?;
let mut lengths = state.db.cred_id_lengths().await?;
if lengths.is_empty() {
lengths.push(32);
}
// Always exactly `PASSKEY_LIMIT` entries. No account may hold more, so the
// list never has to grow past the padding and its length says nothing.
for index in options.public_key.allow_credentials.len()..PASSKEY_LIMIT {
options
.public_key
.allow_credentials
.push(decoy(&secret, name, index as u32, &lengths));
}
Ok(Json(challenge(pending, &options)?))
}
/// POST `{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}`.
///
/// Either signs the user in, or — for an account that needs both factors and
/// started with the passkey — asks for the password next.
pub async fn login_finish(
State(state): State<Arc<AppState>>,
Rp(rp): Rp,
Json(body): Json<PasskeyLoginFinish>,
) -> Result<Response, ApiError> {
let Some(pending) = crate::webauthn::take(&body.state_id) else {
return Err(challenge_expired());
};
let cred: PublicKeyCredential =
serde_json::from_str(&body.credential).map_err(|_| bad_credential())?;
let (user_id, second_factor, result) = match pending {
Pending::Authenticate {
user_id,
state: auth_state,
second_factor,
} => {
let res = rp
.finish_passkey_authentication(&cred, &auth_state)
.map_err(webauthn_failed)?;
(user_id, second_factor, res)
}
Pending::Discoverable { state: disc, decoy } => {
// The user handle comes from the credential, so it is only a
// claim until `finish_discoverable_authentication` checks the
// signature against that account's own keys below.
let (wid, _) = rp
.identify_discoverable_authentication(&cred)
.map_err(webauthn_failed)?;
let user = state
.db
.find_user_by_webauthn_id(&wid)
.await?
.filter(|u| u.active)
.ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?;
let keys: Vec<DiscoverableKey> = load_passkeys(&state, user.id)
.await?
.iter()
.map(|(_, k)| k.into())
.collect();
let res = rp
.finish_discoverable_authentication(&cred, *disc, &keys)
.map_err(webauthn_failed)?;
// The name this challenge was issued for does not exist. The
// ceremony was real so that it could not be told apart from a
// real one, and it is verified before being refused for the same
// reason. Signing this passkey's owner in instead would answer
// the question the whole padding is there to swallow.
if decoy {
return Err(webauthn_failed(WebauthnError::CredentialNotFound));
}
(user.id, false, res)
}
// Any other handle names a different ceremony. Refusing keeps a
// registration challenge from being answered as a sign-in.
_ => return Err(challenge_expired()),
};
record_use(&state, user_id, &result).await?;
let user = state
.db
.find_user_by_id(user_id)
.await?
.filter(|u| u.active)
.ok_or_else(|| webauthn_failed(WebauthnError::CredentialNotFound))?;
if user.auth_mode == AuthMode::Both && !second_factor {
let state_id = crate::webauthn::put(Pending::NeedsPassword { user_id });
return Ok(Json(LoginResp {
ok: false,
password_required: Some(PasswordStep {
name: user.name,
state_id,
}),
..Default::default()
})
.into_response());
}
sign_in(&state, user_id).await
}
/// Persist what the assertion changed: the signature counter and backup
/// flags move, and the settings list shows when a passkey was last used.
async fn record_use(
state: &AppState,
user_id: i64,
result: &AuthenticationResult,
) -> Result<(), ApiError> {
let Some((id, mut key)) = load_passkeys(state, user_id)
.await?
.into_iter()
.find(|(_, k)| k.cred_id() == result.cred_id())
else {
return Ok(());
};
key.update_credential(result);
let encoded = serde_json::to_string(&key).unwrap_or_default();
if !encoded.is_empty() {
state.db.passkey_used(id, &encoded).await?;
}
Ok(())
}
/// Create the session and send its cookie.
pub(crate) async fn sign_in(state: &AppState, user_id: i64) -> Result<Response, ApiError> {
let token = auth::random_token();
state.db.create_session(user_id, &token).await?;
let mut res = Json(LoginResp {
ok: true,
..Default::default()
})
.into_response();
res.headers_mut().insert(
header::SET_COOKIE,
session_cookie(&token, state.https).parse().unwrap(),
);
Ok(res)
}
/// Ask the authenticator to store the credential itself.
///
/// `start_passkey_registration` sends `residentKey: "discouraged"`, which
/// tells a password manager *not* to make a discoverable passkey — and they
/// obey it, so every credential would then need the account name typed in to
/// be found again. There is no builder switch for this on the passkey API,
/// hence the patch.
///
/// Only the request changes, not what is accepted: an authenticator with no
/// room for a resident key still registers, and the `credProps` extension
/// reports what actually happened. Enforcing it would lock out the older
/// security keys this server deliberately still supports.
fn ask_for_discoverable(options: &mut CreationChallengeResponse) {
match options.public_key.authenticator_selection.as_mut() {
Some(sel) => {
sel.resident_key = Some(ResidentKeyRequirement::Required);
// `require_resident_key` is the CTAP1-era boolean, consulted only
// when `residentKey` is absent. Some older keys fail outright on
// it, so it stays false.
}
// `webauthn-rs` always sends this block today. If a future version
// stops, every new passkey silently goes back to needing a typed name.
None => tracing::warn!("no authenticatorSelection to ask for a discoverable credential"),
}
}
/// Park a ceremony's state and pair its handle with the browser's options.
pub(crate) fn challenge<T: serde::Serialize>(
pending: Pending,
options: &T,
) -> Result<PasskeyChallenge, ApiError> {
let options = serde_json::to_string(options).map_err(|e| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("cannot encode the challenge: {e}"),
)
})?;
Ok(PasskeyChallenge {
state_id: crate::webauthn::put(pending),
options,
})
}
Mserver/src/auth.rs
@@ -153,6 +153,22 @@ pub fn forget_verified() {
VERIFIED.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
/// Drop one subject's cached credentials.
///
/// The self-service credential routes use this rather than [`forget_verified`].
/// Any user can reach them, and clearing the whole map would make every open
/// mount on the server pay for Argon2 again.
///
/// A share whose id happens to equal `subject` is dropped too, because the
/// realms share one value space. That costs one extra verification, nothing
/// more.
pub fn forget_verified_for(subject: i64) {
VERIFIED
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|_, (id, _)| *id != subject);
}
/// A keyed hash of the credential, never the credential itself. The pepper is
/// fresh per process, so a dump of the map alone yields no passwords.
fn cache_key(realm: i64, name: &str, password: &str) -> [u8; 32] {
Mserver/src/db.rs
@@ -1,11 +1,11 @@
use std::path::Path;
use std::sync::Arc;
pub use api_types::Mode;
pub use api_types::{AuthMode, Mode};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 9;
const SCHEMA_VERSION: i64 = 10;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -28,6 +28,24 @@ impl ToSql for SqlMode {
}
}
/// SQL adapter for [`AuthMode`], for the same reason as [`SqlMode`].
struct SqlAuthMode(AuthMode);
impl FromSql for SqlAuthMode {
fn column_result(v: ValueRef<'_>) -> FromSqlResult<Self> {
let s = v.as_str()?;
AuthMode::from_wire(s)
.map(SqlAuthMode)
.ok_or_else(|| FromSqlError::Other(format!("unknown auth mode {s:?}").into()))
}
}
impl ToSql for SqlAuthMode {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(self.0.as_str()))
}
}
#[derive(Debug, Clone)]
pub struct User {
pub id: i64,
@@ -45,6 +63,51 @@ pub struct User {
/// Profile setting: the root the UI opens by default. May point at a
/// root the user no longer has; the API filters that out.
pub default_root_id: Option<i64>,
/// What this account needs to sign in.
pub auth_mode: AuthMode,
/// Whether a password is set. False means the account signs in with
/// passkeys only. See [`NO_PASSWORD`].
pub has_password: bool,
}
/// The `pass_hash` of an account with no password.
///
/// A sentinel rather than NULL: `users.pass_hash` is `NOT NULL`, and SQLite
/// cannot drop that constraint without rebuilding the table. Nothing verifies
/// against it — [`Db::verify_password`] swaps in [`DUMMY_HASH`] so the reject
/// costs the same as a wrong password, and `PasswordHash::new("")` fails
/// anyway.
pub const NO_PASSWORD: &str = "";
/// How many passkeys one account may hold.
///
/// Also the exact number of credentials a named sign-in challenge lists. The
/// two are one number on purpose: the challenge pads a short list with decoys
/// so its length says nothing about the account, and that only works while no
/// account can push past the padding.
pub const PASSKEY_LIMIT: usize = 8;
/// What [`Db::delete_passkey`] did.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasskeyDeleted {
Gone,
/// No such passkey, or it belongs to someone else.
NotFound,
/// Removing it would have left the account unreachable.
LastCredential,
}
/// One registered passkey. `passkey` is the serialized `webauthn-rs`
/// credential; everything else is for the settings list.
#[derive(Debug, Clone)]
pub struct PasskeyRow {
pub id: i64,
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub discoverable: Option<bool>,
/// `webauthn_rs::prelude::Passkey` as JSON.
pub passkey: String,
}
#[derive(Debug, Clone)]
@@ -93,6 +156,18 @@ pub struct ShareWithCreator {
pub creator_active: bool,
}
/// The columns [`map_user`] reads, in order. Every SELECT that builds a
/// [`User`] uses one of these two, so a new column is added in one place.
/// `USER_COLS_U` is the same list qualified for the queries that join
/// `users u` against `user_roots`.
const USER_COLS: &str = "id, name, is_admin != 0, active != 0, single_click != 0,
thumbnails != 0, language, default_root_id, auth_mode, pass_hash != ''";
const USER_COLS_U: &str = "u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language, u.default_root_id, u.auth_mode, u.pass_hash != ''";
/// How many columns the two lists above cover. The joined queries read the
/// root columns starting here.
const USER_COL_COUNT: usize = 10;
/// Every query can fail, and every caller decides what to do about it.
///
/// Earlier versions swallowed read errors and returned a default (an empty
@@ -210,6 +285,33 @@ impl Db {
// root must not fail because of this column.
conn.execute_batch("ALTER TABLE users ADD COLUMN default_root_id INTEGER")?;
}
if version < 10 {
// Passkeys, and how they combine with the password.
//
// `webauthn_id` is the WebAuthn user handle: a random uuid the
// authenticator stores inside a discoverable credential and hands
// back at sign-in. It must never change once a passkey exists, or
// that passkey can no longer be traced to its account. Filled in
// lazily on the first registration, so accounts that never use a
// passkey keep it NULL.
conn.execute_batch(
"ALTER TABLE users ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'either';
ALTER TABLE users ADD COLUMN webauthn_id TEXT;
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
cred_id BLOB NOT NULL UNIQUE,
passkey TEXT NOT NULL,
name TEXT NOT NULL,
discoverable INTEGER,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_webauthn_id
ON users(webauthn_id) WHERE webauthn_id IS NOT NULL;",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -257,6 +359,8 @@ impl Db {
thumbnails: true,
language: None,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
}))
}
@@ -264,86 +368,26 @@ impl Db {
// The guard is scoped to the query alone. Argon2 below is slow by
// design; holding the single connection lock across it would make one
// login serialize every other database access.
type UserRow = (
i64,
String,
bool,
String,
bool,
bool,
bool,
Option<String>,
Option<i64>,
);
let row: Option<UserRow> = {
let row: Option<(User, String)> = {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, pass_hash, active != 0, single_click != 0,
thumbnails != 0, language, default_root_id
FROM users WHERE name = ?1",
&format!("SELECT {USER_COLS}, pass_hash FROM users WHERE name = ?1"),
[name],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
))
},
|r| Ok((map_user(r)?, r.get(USER_COL_COUNT)?)),
)
.optional()?
};
// An unknown or disabled name still pays for one Argon2 verify, so the
// response time does not reveal which names exist.
let (row, hash) = match row {
Some((
id,
name,
is_admin,
hash,
active,
single_click,
thumbnails,
language,
default_root_id,
)) if active => (
Some((
id,
name,
is_admin,
single_click,
thumbnails,
language,
default_root_id,
)),
hash,
),
// An unknown name, a disabled account and a passkey-only account all
// still pay for one Argon2 verify, so the response time does not tell
// them apart from a real account with a wrong password.
let (user, hash) = match row {
Some((u, h)) if u.active && u.has_password => (Some(u), h),
_ => (None, DUMMY_HASH.clone()),
};
let ok = crate::auth::verify_password_async(password, &hash).await;
let Some((id, name, is_admin, single_click, thumbnails, language, default_root_id)) = row
else {
return Ok(None);
};
Ok(ok.then_some(User {
id,
name,
is_admin,
active: true,
single_click,
thumbnails,
language,
default_root_id,
}))
Ok(user.filter(|_| ok))
}
// ---------- sessions ----------
pub async fn create_session(&self, user_id: i64, token: &str) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
@@ -367,16 +411,14 @@ impl Db {
token: &str,
) -> DbResult<Option<(User, Vec<RootRow>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language, u.default_root_id,
r.id, r.path, r.mode
let mut stmt = c.prepare_cached(&format!(
"SELECT {USER_COLS_U}, r.id, r.path, r.mode
FROM sessions s
JOIN users u ON u.id = s.user_id
LEFT JOIN user_roots r ON r.user_id = u.id
WHERE s.token = ?1 AND u.active = 1
ORDER BY r.id",
)?;
))?;
// One row per root; a user without roots still returns one row, with
// the root columns NULL.
let mut user: Option<User> = None;
@@ -386,11 +428,11 @@ impl Db {
if user.is_none() {
user = Some(map_user(r)?);
}
if let Some(id) = r.get::<_, Option<i64>>(8)? {
if let Some(id) = r.get::<_, Option<i64>>(USER_COL_COUNT)? {
roots.push(RootRow {
id,
path: r.get(9)?,
mode: r.get::<_, SqlMode>(10)?.0,
path: r.get(USER_COL_COUNT + 1)?,
mode: r.get::<_, SqlMode>(USER_COL_COUNT + 2)?.0,
});
}
}
@@ -420,14 +462,12 @@ impl Db {
/// both, and a per-user roots query would be one round trip per user.
pub async fn all_users_with_roots(&self) -> DbResult<Vec<(User, Vec<RootRow>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0,
u.thumbnails != 0, u.language, u.default_root_id,
r.id, r.path, r.mode
let mut stmt = c.prepare_cached(&format!(
"SELECT {USER_COLS_U}, r.id, r.path, r.mode
FROM users u
LEFT JOIN user_roots r ON r.user_id = u.id
ORDER BY u.id, r.id",
)?;
))?;
// Rows arrive grouped by user, so a new user id starts a new group.
let mut out: Vec<(User, Vec<RootRow>)> = Vec::new();
let mut rows = stmt.query([])?;
@@ -436,11 +476,11 @@ impl Db {
if out.last().is_none_or(|(u, _)| u.id != uid) {
out.push((map_user(r)?, Vec::new()));
}
if let Some(id) = r.get::<_, Option<i64>>(8)? {
if let Some(id) = r.get::<_, Option<i64>>(USER_COL_COUNT)? {
out.last_mut().expect("pushed above").1.push(RootRow {
id,
path: r.get(9)?,
mode: r.get::<_, SqlMode>(10)?.0,
path: r.get(USER_COL_COUNT + 1)?,
mode: r.get::<_, SqlMode>(USER_COL_COUNT + 2)?.0,
});
}
}
@@ -450,9 +490,7 @@ impl Db {
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, thumbnails != 0,
language, default_root_id
FROM users WHERE id = ?1",
&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"),
[id],
map_user,
)
@@ -462,9 +500,7 @@ impl Db {
pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0, thumbnails != 0,
language, default_root_id
FROM users WHERE name = ?1",
&format!("SELECT {USER_COLS} FROM users WHERE name = ?1"),
[name],
map_user,
)
@@ -512,6 +548,8 @@ impl Db {
thumbnails: true,
language: None,
default_root_id: None,
auth_mode: AuthMode::Either,
has_password: true,
})
}
@@ -563,8 +601,19 @@ impl Db {
) -> DbResult<()> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
// An admin sets a password to get someone back into a locked-out
// account, so the passkeys and the requirement to use one go with it.
// The account is left with exactly one way in, which is the one the
// admin just handed over. Doing it always rather than on request means
// a reset cannot half-happen: there is no way to set a password and
// leave a second factor the user no longer has.
if let Some(h) = pass_hash {
set_password(&tx, id, h)?;
tx.execute("DELETE FROM passkeys WHERE user_id = ?1", [id])?;
tx.execute(
"UPDATE users SET auth_mode = ?1 WHERE id = ?2",
params![SqlAuthMode(AuthMode::Either), id],
)?;
}
if let Some(a) = is_admin {
tx.execute(
@@ -590,6 +639,228 @@ impl Db {
tx.commit()
}
// ---------- credentials: password, passkeys, sign-in mode ----------
/// Set the password. Leaves every session alone, so the caller must pair
/// this with [`Db::delete_other_sessions`] — see
/// [`crate::api::passkeys`]'s `invalidate_elsewhere`. The admin path uses
/// [`set_password`] instead, which ends every session including its own.
///
/// Only for setting a real one. Clearing it is [`Db::clear_user_password`],
/// which has a rule to keep.
pub async fn set_password_keeping_sessions(&self, id: i64, pass_hash: &str) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET pass_hash = ?1 WHERE id = ?2",
params![pass_hash, id],
)?;
Ok(())
}
/// Leave the account on its passkeys alone. `false` means that would have
/// locked it out, so nothing changed.
pub async fn clear_user_password(&self, id: i64) -> DbResult<bool> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
tx.execute(
"UPDATE users SET pass_hash = ?1 WHERE id = ?2",
params![NO_PASSWORD, id],
)?;
commit_if_reachable(tx, id)
}
/// `false` means the account does not satisfy the new mode, so it stands.
pub async fn set_user_auth_mode(&self, id: i64, mode: AuthMode) -> DbResult<bool> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
tx.execute(
"UPDATE users SET auth_mode = ?1 WHERE id = ?2",
params![SqlAuthMode(mode), id],
)?;
commit_if_reachable(tx, id)
}
/// Drop every session of this user except `keep`.
///
/// Called after any credential change. Otherwise a session stolen before
/// the change keeps working for its full 30 days.
pub async fn delete_other_sessions(&self, user_id: i64, keep: &str) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"DELETE FROM sessions WHERE user_id = ?1 AND token != ?2",
params![user_id, keep],
)?;
Ok(())
}
/// This account's WebAuthn user handle, creating it on first use.
///
/// Stable for the lifetime of the account: a discoverable passkey stores
/// this value and hands it back at sign-in, so changing it would orphan
/// every existing passkey.
pub async fn user_webauthn_id(&self, id: i64) -> DbResult<uuid::Uuid> {
let c = self.0.lock().await;
let existing: Option<String> = c
.query_row("SELECT webauthn_id FROM users WHERE id = ?1", [id], |r| {
r.get(0)
})
.optional()?
.flatten();
if let Some(parsed) = existing
.as_deref()
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
return Ok(parsed);
}
let fresh = uuid::Uuid::new_v4();
c.execute(
"UPDATE users SET webauthn_id = ?1 WHERE id = ?2",
params![fresh.to_string(), id],
)?;
Ok(fresh)
}
/// The account a discoverable credential's user handle points at.
pub async fn find_user_by_webauthn_id(&self, wid: &uuid::Uuid) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
&format!("SELECT {USER_COLS} FROM users WHERE webauthn_id = ?1"),
[wid.to_string()],
map_user,
)
.optional()
}
pub async fn user_passkeys(&self, user_id: i64) -> DbResult<Vec<PasskeyRow>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT id, name, created_at, last_used_at, discoverable, passkey
FROM passkeys WHERE user_id = ?1 ORDER BY id",
)?;
let rows = stmt.query_map([user_id], map_passkey)?;
rows.collect()
}
pub async fn count_passkeys(&self, user_id: i64) -> DbResult<i64> {
let c = self.0.lock().await;
c.query_row(
"SELECT COUNT(*) FROM passkeys WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)
}
/// The per-install secret behind the decoy credentials a named passkey
/// challenge is padded with. Created on first use, so no migration.
pub async fn decoy_secret(&self) -> DbResult<String> {
let c = self.0.lock().await;
let existing: Option<String> = c
.query_row(
"SELECT value FROM meta WHERE key = 'decoy_secret'",
[],
|r| r.get(0),
)
.optional()?;
if let Some(secret) = existing {
return Ok(secret);
}
let fresh = crate::auth::random_token();
c.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('decoy_secret', ?1)",
[&fresh],
)?;
Ok(fresh)
}
/// The length in bytes of every credential id registered on this server,
/// one entry per credential.
///
/// Decoys draw their length from this list. Authenticators disagree on it —
/// a security key emits 64 bytes, a platform key often 16 or 32 — so a
/// decoy of one fixed length would stand out next to the real entries.
///
/// Duplicates are kept deliberately. Drawing from the distinct lengths
/// would make a rare length as likely as a common one, and decoys that do
/// not match how the install actually looks are the thing worth avoiding.
pub async fn cred_id_lengths(&self) -> DbResult<Vec<usize>> {
let c = self.0.lock().await;
let mut stmt = c.prepare("SELECT length(cred_id) FROM passkeys ORDER BY id")?;
let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
rows.map(|r| r.map(|n| n.max(1) as usize))
.collect::<Result<Vec<_>, _>>()
}
/// Store a freshly registered passkey. A duplicate `cred_id` is a unique
/// violation, which is the intended answer: the same credential must not
/// be registered twice, not even to a second account.
///
/// `None` means the account is already at [`PASSKEY_LIMIT`]. The count and
/// the insert share one transaction, so two registrations landing together
/// cannot put the account one over and give its sign-in challenge a
/// telltale length.
pub async fn add_passkey(
&self,
user_id: i64,
cred_id: &[u8],
passkey: &str,
name: &str,
discoverable: Option<bool>,
) -> DbResult<Option<PasskeyRow>> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let held: i64 = tx.query_row(
"SELECT COUNT(*) FROM passkeys WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)?;
if held as usize >= PASSKEY_LIMIT {
return Ok(None);
}
tx.execute(
"INSERT INTO passkeys (user_id, cred_id, passkey, name, discoverable, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![user_id, cred_id, passkey, name, discoverable, now()],
)?;
let id = tx.last_insert_rowid();
let row = tx.query_row(
"SELECT id, name, created_at, last_used_at, discoverable, passkey
FROM passkeys WHERE id = ?1",
[id],
map_passkey,
)?;
tx.commit()?;
Ok(Some(row))
}
pub async fn delete_passkey(&self, id: i64, user_id: i64) -> DbResult<PasskeyDeleted> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let hit = tx.execute(
"DELETE FROM passkeys WHERE id = ?1 AND user_id = ?2",
params![id, user_id],
)? > 0;
if !hit {
tx.rollback()?;
return Ok(PasskeyDeleted::NotFound);
}
Ok(if commit_if_reachable(tx, user_id)? {
PasskeyDeleted::Gone
} else {
PasskeyDeleted::LastCredential
})
}
/// Record a successful assertion: the re-serialized credential (its
/// signature counter and backup flags may have moved) and the time.
pub async fn passkey_used(&self, id: i64, passkey: &str) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE passkeys SET passkey = ?1, last_used_at = ?2 WHERE id = ?3",
params![passkey, now(), id],
)?;
Ok(())
}
/// Delete a user. `false` means no row matched.
pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
@@ -827,7 +1098,38 @@ fn set_password(tx: &rusqlite::Transaction<'_>, id: i64, pass_hash: &str) -> DbR
Ok(())
}
/// Column order matched by the four `users` SELECTs above.
/// Commit a credential change, or undo it if the account would be left with
/// no way to sign in.
///
/// The handlers check the same rules first, so the person gets a message that
/// says which rule. This is the guarantee behind those checks: a count read
/// before the write can already be stale when the write lands, and two
/// requests racing could otherwise take away the last credential between them.
/// `false` means the change was rolled back.
fn commit_if_reachable(tx: rusqlite::Transaction<'_>, user_id: i64) -> DbResult<bool> {
let (has_password, mode) = tx.query_row(
"SELECT pass_hash != '', auth_mode FROM users WHERE id = ?1",
[user_id],
|r| Ok((r.get::<_, bool>(0)?, r.get::<_, SqlAuthMode>(1)?)),
)?;
let passkeys: i64 = tx.query_row(
"SELECT COUNT(*) FROM passkeys WHERE user_id = ?1",
[user_id],
|r| r.get(0),
)?;
let reachable = match mode.0 {
AuthMode::Either => has_password || passkeys > 0,
AuthMode::Both => has_password && passkeys > 0,
};
if !reachable {
tx.rollback()?;
return Ok(false);
}
tx.commit()?;
Ok(true)
}
/// Column order matched by [`USER_COLS`] and [`USER_COLS_U`].
fn map_user(r: &rusqlite::Row) -> DbResult<User> {
Ok(User {
id: r.get(0)?,
@@ -838,6 +1140,20 @@ fn map_user(r: &rusqlite::Row) -> DbResult<User> {
thumbnails: r.get(5)?,
language: r.get(6)?,
default_root_id: r.get(7)?,
auth_mode: r.get::<_, SqlAuthMode>(8)?.0,
has_password: r.get(9)?,
})
}
/// Column order matched by the `passkeys` SELECTs above.
fn map_passkey(r: &rusqlite::Row) -> DbResult<PasskeyRow> {
Ok(PasskeyRow {
id: r.get(0)?,
name: r.get(1)?,
created_at: r.get(2)?,
last_used_at: r.get(3)?,
discoverable: r.get(4)?,
passkey: r.get(5)?,
})
}
Mserver/src/lib.rs
@@ -20,6 +20,7 @@ pub mod db;
pub mod error;
pub mod fs;
pub mod thumb;
pub mod webauthn;
use crate::cli::Cli;
use crate::db::Db;
Aserver/src/webauthn.rs
@@ -0,0 +1,251 @@
//! WebAuthn (passkey) support: the relying-party instance and the short-lived
//! state of an in-flight ceremony.
//!
//! Both WebAuthn ceremonies take two round trips. The server issues a
//! challenge, the browser answers it, and the server must still hold the
//! challenge it issued to check the answer. That state lives in [`PENDING`]
//! here, keyed by an opaque handle the client echoes back.
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::{HeaderMap, StatusCode, Uri, header};
use webauthn_rs::prelude::*;
use crate::api::common::HasState;
use crate::error::{ApiError, AppState};
/// The relying party, as an extractor.
///
/// Built per request rather than once at startup, because the RP ID is the
/// domain the browser is on, and nothing tells us that at startup unless
/// `--public-url` is set. A reverse proxy that rewrites the host would break
/// this; set `--public-url` there.
pub struct Rp(pub Webauthn);
impl<S> FromRequestParts<S> for Rp
where
S: HasState + Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
relying_party(state.state(), &parts.uri, &parts.headers).map(Rp)
}
}
pub fn relying_party(
state: &AppState,
uri: &Uri,
headers: &HeaderMap,
) -> Result<Webauthn, ApiError> {
let origin = origin(state, uri, headers).ok_or_else(misconfigured)?;
// `domain()` is None for a bare IP address, and WebAuthn does not work on
// one at all — the RP ID has to be a registrable domain.
let rp_id = origin.domain().ok_or_else(misconfigured)?;
WebauthnBuilder::new(rp_id, &origin)
.and_then(|b| b.rp_name("filebrowser-ng").build())
.map_err(|e| {
tracing::error!(error = ?e, %origin, "cannot build the WebAuthn relying party");
misconfigured()
})
}
/// The origin the browser will report, as far as the server can tell.
///
/// The host arrives in one of two places depending on the protocol version.
/// HTTP/1.1 sends a `Host` header; HTTP/2 sends `:authority`, which hyper
/// puts in the URI and does *not* mirror into a header. Reading only one of
/// them would break passkeys behind an h2 reverse proxy.
fn origin(state: &AppState, uri: &Uri, headers: &HeaderMap) -> Option<Url> {
if let Some(public) = &state.public_url {
return Url::parse(public).ok();
}
let host = match uri.authority() {
Some(a) => a.as_str().to_string(),
None => headers.get(header::HOST)?.to_str().ok()?.to_string(),
};
let scheme = if state.https { "https" } else { "http" };
Url::parse(&format!("{scheme}://{host}")).ok()
}
/// The client is told nothing but "not available here".
///
/// Some of the routes that reach this need no session, so the hint belongs in
/// the log. It names the deployment's configuration, which is the operator's
/// business and not a visitor's.
fn misconfigured() -> ApiError {
tracing::error!("passkeys need a domain name for the relying party; set --public-url");
ApiError::localized(
StatusCode::INTERNAL_SERVER_ERROR,
"passkeys are not available here",
"err_passkey_unavailable",
)
}
// ---------------------------------------------------------------------------
// In-flight ceremonies
// ---------------------------------------------------------------------------
/// What the second leg of a ceremony needs to know.
pub enum Pending {
/// Registering a passkey for a signed-in user.
Register {
user_id: i64,
state: Box<PasskeyRegistration>,
},
/// Signing in with a known account: the challenge names that account's
/// credentials, so the answer can only come from one of them.
Authenticate {
user_id: i64,
state: Box<PasskeyAuthentication>,
/// True when the password already passed and this is the second
/// factor. Only then does finishing create a session directly.
second_factor: bool,
},
/// Signing in without a name. The account is only known once the browser
/// answers, because the answer carries the user handle.
Discoverable {
state: Box<DiscoverableAuthentication>,
/// True when this stands in for a name the server does not know.
///
/// The ceremony is real so that it cannot be told apart from one for
/// an account that exists. Finishing it must still fail, or answering
/// with any passkey would sign that passkey's owner in and turn the
/// answer into the name oracle the padding exists to prevent.
decoy: bool,
},
/// A passkey passed, but the account also requires its password. Holds
/// the identified user until `POST /api/auth/login` supplies it.
NeedsPassword { user_id: i64 },
}
impl Pending {
/// Whether a stranger could have made this one.
///
/// Only these count against [`MAX_ANONYMOUS`]. The rest cost a session, a
/// correct password or a real authenticator signature to produce, and that
/// limits them better than a number here could. It also keeps a flood of
/// the cheap kind from evicting a sign-in that is halfway done.
fn anonymous(&self) -> bool {
matches!(
self,
Pending::Discoverable { .. }
| Pending::Authenticate {
second_factor: false,
..
}
)
}
}
/// How long a client has to answer a challenge.
///
/// The browser's own timeout is shorter, but a conditional-UI challenge sits
/// in an autofill dropdown until the user touches the field.
const TTL: Duration = Duration::from_secs(300);
/// Upper bound on outstanding ceremonies nobody had to authenticate for.
///
/// Conditional UI creates one on every load of the login page, most of which
/// are never answered. Without a cap an unauthenticated visitor could grow
/// this map without limit.
///
/// ponytail: the authenticated kinds are uncapped. Registering needs a
/// session, so an account could loop it; the ceiling is one TTL of requests,
/// tens of megabytes at a realistic rate. Cap them too if that ever bites.
const MAX_ANONYMOUS: usize = 4096;
/// ponytail: process-wide map, like `auth::LOGIN_FAILURES` and
/// `auth::VERIFIED`. Move it to the DB if the server is ever scaled out —
/// today a challenge issued by one node could not be answered on another.
static PENDING: LazyLock<std::sync::Mutex<HashMap<String, (Pending, Instant)>>> =
LazyLock::new(Default::default);
/// Store a ceremony and return the handle the client sends back.
pub fn put(pending: Pending) -> String {
let id = crate::auth::random_token();
let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, (_, at)| at.elapsed() < TTL);
// Still full of live anonymous entries: drop the oldest of those to make
// room. A visitor whose challenge is evicted here just retries, and a
// half-finished sign-in is never the thing that gets dropped.
while pending.anonymous()
&& map.values().filter(|(p, _)| p.anonymous()).count() >= MAX_ANONYMOUS
{
let Some(oldest) = map
.iter()
.filter(|(_, (p, _))| p.anonymous())
.min_by_key(|(_, (_, at))| *at)
.map(|(k, _)| k.clone())
else {
break;
};
map.remove(&oldest);
}
map.insert(id.clone(), (pending, Instant::now()));
id
}
/// Take a ceremony out of the map. One handle answers one challenge: a replay
/// of the same handle finds nothing.
pub fn take(id: &str) -> Option<Pending> {
let mut map = PENDING.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, (_, at)| at.elapsed() < TTL);
map.remove(id).map(|(p, _)| p)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_handle_answers_once() {
let id = put(Pending::NeedsPassword { user_id: 7 });
assert!(matches!(
take(&id),
Some(Pending::NeedsPassword { user_id: 7 })
));
assert!(take(&id).is_none(), "a handle must not be reusable");
assert!(take("never-issued").is_none());
}
/// An anonymous ceremony, the kind `login_begin` hands out to a stranger.
fn anonymous_ceremony() -> Pending {
let url = Url::parse("https://example.com").unwrap();
let rp = WebauthnBuilder::new("example.com", &url)
.unwrap()
.build()
.unwrap();
let (_, disc) = rp.start_discoverable_authentication().unwrap();
Pending::Discoverable {
state: Box::new(disc),
decoy: false,
}
}
#[test]
fn a_flood_of_strangers_stays_bounded_and_spares_a_sign_in() {
// A sign-in that already passed one factor, parked mid-flight.
let halfway = put(Pending::NeedsPassword { user_id: 1 });
for _ in 0..MAX_ANONYMOUS + 50 {
put(anonymous_ceremony());
}
let anon = PENDING
.lock()
.unwrap()
.values()
.filter(|(p, _)| p.anonymous())
.count();
assert!(anon <= MAX_ANONYMOUS, "{anon} entries outgrew the cap");
assert!(
take(&halfway).is_some(),
"a flood must not evict a half-finished sign-in"
);
}
}
Aserver/tests/api_passkeys.rs
@@ -0,0 +1,881 @@
//! Self-service credentials: changing and removing the password, the
//! passkey list, and the rules that keep an account reachable.
//!
//! No real authenticator exists here, so nothing verifies a signature — that
//! is `webauthn-rs`' job and it has its own tests. What these tests cover is
//! everything around it: which combinations the server accepts, what it
//! refuses, and that a half-finished sign-in never becomes a session.
mod common;
use axum::http::{Method, StatusCode};
use base64::Engine as _;
use common::*;
use serde_json::json;
/// A syntactically valid stored passkey.
///
/// The key is all zeroes, so it can never verify anything. Every test here
/// either counts passkeys or checks that a ceremony is *refused*, and for
/// both a credential that parses is enough.
const STORED_PASSKEY: &str = r#"{"cred":{"cred_id":"AQIDBA","cred":{"type_":"ES256","key":{"EC_EC2":{"curve":"SECP256R1","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","y":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}},"counter":0,"transports":null,"user_verified":true,"backup_eligible":false,"backup_state":false,"registration_policy":"required","extensions":{"cred_protect":"NotRequested","hmac_create_secret":"NotRequested","appid":"NotRequested","cred_props":"NotRequested"},"attestation":{"data":"None","metadata":"None"},"attestation_format":"none"}}"#;
/// Put a passkey on an account without a browser.
///
/// `label` only has to be unique; the stored id is four bytes derived from it.
/// A real registration stores exactly the credential id that is inside the
/// passkey JSON, and `STORED_PASSKEY` carries a four-byte one. The challenge
/// padding reads its decoy lengths from the stored ids, so the two must agree.
async fn give_passkey(env: &Env, user_id: i64, label: &[u8]) {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut h = DefaultHasher::new();
label.hash(&mut h);
let cred_id = (h.finish() as u32).to_le_bytes();
env.state
.db
.add_passkey(user_id, &cred_id, STORED_PASSKEY, "Test key", Some(true))
.await
.unwrap()
.expect("the account was already at the passkey limit");
}
fn basic(name: &str, password: &str) -> String {
let raw = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}"));
format!("Basic {raw}")
}
// ---------------------------------------------------------------------------
// Password
// ---------------------------------------------------------------------------
#[tokio::test]
async fn a_short_new_password_is_refused() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin
.post_json("/api/auth/password", &json!({ "new_password": "short" }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn changing_the_password_ends_every_other_session() {
let env = Env::new().await;
let admin = env.admin().await;
// A second sign-in, as if from another browser.
let other = login(&env, "admin", "admin1234").await;
assert_eq!(other.get("/api/auth/me").await.status, StatusCode::OK);
let r = admin
.post_json(
"/api/auth/password",
&json!({ "new_password": "brandnew1" }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
// The session that made the change survives; the other one does not.
assert_eq!(admin.get("/api/auth/me").await.status, StatusCode::OK);
assert_eq!(
other.get("/api/auth/me").await.status,
StatusCode::UNAUTHORIZED
);
let _ = login(&env, "admin", "brandnew1").await;
}
#[tokio::test]
async fn the_password_cannot_go_while_it_is_the_only_credential() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.delete("/api/auth/password").await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert_eq!(r.json()["code"], "err_password_last_credential");
}
#[tokio::test]
async fn removing_the_password_leaves_a_passkey_only_account() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-only").await;
let r = admin.delete("/api/auth/password").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
// The session that did it keeps working, and now says so.
let me = admin.get("/api/auth/me").await.json();
assert_eq!(me["user"]["has_password"], false);
// The old password is not "still valid but unused" — it is gone.
let c = Client::new(env.app.clone());
let r = c
.post_json(
"/api/auth/login",
&json!({ "name": "admin", "password": "admin1234" }),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert!(session_cookie(&r).is_none());
}
#[tokio::test]
async fn a_passkey_only_account_can_set_a_password_again() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-again").await;
admin.delete("/api/auth/password").await;
// Setting a first password on a passkey-only account is the same call.
let r = admin
.post_json(
"/api/auth/password",
&json!({ "new_password": "secondgo1" }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let _ = login(&env, "admin", "secondgo1").await;
}
// ---------------------------------------------------------------------------
// Passkey list
// ---------------------------------------------------------------------------
#[tokio::test]
async fn the_passkey_list_is_per_account_and_carries_no_key_material() {
let env = Env::new().await;
let admin = env.admin().await;
let admin_id = user_id(&admin, "admin").await;
create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
let bob_id = user_id(&admin, "bob").await;
give_passkey(&env, admin_id, b"cred-admin").await;
give_passkey(&env, bob_id, b"cred-bob").await;
let j = admin.get("/api/auth/passkeys").await.json();
let list = j.as_array().unwrap();
assert_eq!(list.len(), 1, "admin must not see bob's passkey");
assert_eq!(list[0]["name"], "Test key");
assert_eq!(list[0]["discoverable"], true);
assert!(list[0]["last_used_at"].is_null());
assert!(
!j.to_string().contains("cred_id"),
"the list leaked credential internals: {j}"
);
let bob = login(&env, "bob", "bobpass12").await;
assert_ne!(
bob.get("/api/auth/passkeys").await.json()[0]["id"],
list[0]["id"],
"bob sees the admin's passkey"
);
}
#[tokio::test]
async fn the_last_passkey_cannot_go_while_there_is_no_password() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-last").await;
admin.delete("/api/auth/password").await;
let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"]
.as_i64()
.unwrap();
let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert_eq!(r.json()["code"], "err_passkey_last_credential");
assert_eq!(admin.get("/api/auth/passkeys").await.json()[0]["id"], pk_id);
}
#[tokio::test]
async fn one_of_several_passkeys_can_always_go() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-a").await;
give_passkey(&env, id, b"cred-b").await;
let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"]
.as_i64()
.unwrap();
let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
admin
.get("/api/auth/passkeys")
.await
.json()
.as_array()
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn another_accounts_passkey_is_out_of_reach() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
let bob_id = user_id(&admin, "bob").await;
give_passkey(&env, bob_id, b"cred-bob2").await;
let bob = login(&env, "bob", "bobpass12").await;
let pk_id = bob.get("/api/auth/passkeys").await.json()[0]["id"]
.as_i64()
.unwrap();
// Even an admin cannot reach it here: this route is self-service only.
let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
assert_eq!(
bob.get("/api/auth/passkeys")
.await
.json()
.as_array()
.unwrap()
.len(),
1
);
}
// ---------------------------------------------------------------------------
// Sign-in requirement
// ---------------------------------------------------------------------------
#[tokio::test]
async fn requiring_both_needs_both_to_exist() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
// No passkey yet.
let r = admin
.put_json("/api/auth/mode", &json!({ "mode": "both" }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
assert_eq!(r.json()["code"], "err_mode_needs_passkey");
give_passkey(&env, id, b"cred-mode").await;
let r = admin
.put_json("/api/auth/mode", &json!({ "mode": "both" }))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
admin.get("/api/auth/me").await.json()["user"]["auth_mode"],
"both"
);
}
#[tokio::test]
async fn requiring_both_blocks_removing_either_half() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-both").await;
admin
.put_json("/api/auth/mode", &json!({ "mode": "both" }))
.await;
let r = admin.delete("/api/auth/password").await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
assert_eq!(r.json()["code"], "err_required_by_mode");
let pk_id = admin.get("/api/auth/passkeys").await.json()[0]["id"]
.as_i64()
.unwrap();
let r = admin.delete(&format!("/api/auth/passkeys/{pk_id}")).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
assert_eq!(r.json()["code"], "err_required_by_mode");
}
#[tokio::test]
async fn the_password_alone_never_signs_in_an_account_that_requires_both() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-2fa").await;
admin
.put_json("/api/auth/mode", &json!({ "mode": "both" }))
.await;
let c = Client::new(env.app.clone());
let r = c
.post_json(
"/api/auth/login",
&json!({ "name": "admin", "password": "admin1234" }),
)
.await;
// Right password, no session: a passkey challenge comes back instead.
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(
session_cookie(&r).is_none(),
"a session was handed out on the password alone"
);
let j = r.json();
assert_eq!(j["ok"], false);
assert!(j["passkey_challenge"]["state_id"].is_string());
assert!(
j["passkey_challenge"]["options"]
.as_str()
.unwrap()
.contains("challenge"),
"the challenge carries no options: {j}"
);
}
#[tokio::test]
async fn a_wrong_password_reveals_nothing_about_the_second_factor() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-2fa2").await;
admin
.put_json("/api/auth/mode", &json!({ "mode": "both" }))
.await;
let c = Client::new(env.app.clone());
let r = c
.post_json(
"/api/auth/login",
&json!({ "name": "admin", "password": "not-the-password" }),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
assert_eq!(r.json()["code"], "err_invalid_credentials");
}
// ---------------------------------------------------------------------------
// WebDAV
// ---------------------------------------------------------------------------
#[tokio::test]
async fn webdav_refuses_an_account_that_requires_a_passkey() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
// Basic auth works before the switch.
let c = Client::new(env.app.clone());
let r = c
.raw(
Method::from_bytes(b"PROPFIND").unwrap(),
"/dav",
&[
("depth", "1"),
("authorization", &basic("admin", "admin1234")),
],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
give_passkey(&env, id, b"cred-dav").await;
admin
.put_json("/api/auth/mode", &json!({ "mode": "both" }))
.await;
// Basic carries a password and nothing else, so it can no longer stand
// in for both factors.
let r = c
.raw(
Method::from_bytes(b"PROPFIND").unwrap(),
"/dav",
&[
("depth", "1"),
("authorization", &basic("admin", "admin1234")),
],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text());
}
// ---------------------------------------------------------------------------
// Passkey sign-in
// ---------------------------------------------------------------------------
#[tokio::test]
async fn beginning_a_passkey_sign_in_never_says_whether_a_name_exists() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-probe").await;
let c = Client::new(env.app.clone());
// An unknown name, a real name without passkeys, and no name at all must
// be indistinguishable: all three get a usable challenge.
create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
for body in [
json!({ "name": "nobody-here" }),
json!({ "name": "bob" }),
json!({}),
] {
let r = c.post_json("/api/auth/passkey/login", &body).await;
assert_eq!(r.status, StatusCode::OK, "{body}: {}", r.text());
let j = r.json();
assert!(j["state_id"].is_string(), "{body}: {j}");
assert!(j["options"].as_str().unwrap().contains("challenge"));
}
// A name with passkeys gets exactly the same shape. Everything the client
// can see must match, or the difference is the oracle.
let real = begin_named(&c, "admin").await;
let fake = begin_named(&c, "nobody-here").await;
assert_eq!(real, fake, "a named challenge must not depend on the name");
}
#[tokio::test]
async fn a_second_spelling_of_a_name_gives_nothing_away() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-case").await;
let c = Client::new(env.app.clone());
// Account names are case-insensitive, so both spellings reach the same
// account and repeat the same real credential. If the decoys around it
// moved, comparing the two answers would show which entries were real.
let lower = begin_named_raw(&c, "admin").await;
let upper = begin_named_raw(&c, "ADMIN").await;
assert_eq!(lower, upper, "a name's case changed its credential list");
// And an unknown name must not share entries with either spelling of it.
let miss = begin_named_raw(&c, "nobody").await;
let miss_upper = begin_named_raw(&c, "NOBODY").await;
assert_eq!(miss, miss_upper);
assert_ne!(miss, lower);
}
/// The credential ids of a named challenge, in order.
async fn begin_named_raw(c: &Client, name: &str) -> Vec<String> {
let r = c
.post_json("/api/auth/passkey/login", &json!({ "name": name }))
.await;
assert_eq!(r.status, StatusCode::OK, "{name}: {}", r.text());
let opts: serde_json::Value =
serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap();
opts["publicKey"]["allowCredentials"]
.as_array()
.unwrap()
.iter()
.map(|c| c["id"].as_str().unwrap().to_string())
.collect()
}
/// The visible shape of a named challenge, with the parts that are random by
/// design blanked out. What is left must not depend on whether the name exists.
async fn begin_named(c: &Client, name: &str) -> serde_json::Value {
let r = c
.post_json("/api/auth/passkey/login", &json!({ "name": name }))
.await;
assert_eq!(r.status, StatusCode::OK, "{name}: {}", r.text());
let mut opts: serde_json::Value =
serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap();
let key = &mut opts["publicKey"];
key["challenge"] = json!("<challenge>");
// The ids differ between the two by construction. Their count and their
// lengths are what a probe could read, so keep those.
for cred in key["allowCredentials"].as_array_mut().unwrap() {
let len = cred["id"].as_str().unwrap().len();
cred["id"] = json!(len);
}
opts
}
#[tokio::test]
async fn a_challenge_for_an_unknown_name_can_never_sign_anyone_in() {
let env = Env::new().await;
let _ = env.admin().await;
let c = Client::new(env.app.clone());
let r = c
.post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" }))
.await;
let state_id = r.json()["state_id"].as_str().unwrap().to_string();
// The ceremony looks real on purpose. Finishing it must not: a visitor
// holding any passkey for this site could otherwise answer it, get signed
// in as themselves, and read the name's absence off the 200.
let pending = server::webauthn::take(&state_id).expect("the handle was stored");
assert!(
matches!(
pending,
server::webauthn::Pending::Discoverable { decoy: true, .. }
),
"a challenge for an unknown name must be marked as a decoy"
);
}
#[tokio::test]
async fn an_account_cannot_hold_more_passkeys_than_a_challenge_lists() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
for n in 0..server::db::PASSKEY_LIMIT {
give_passkey(&env, id, format!("cred-{n}").as_bytes()).await;
}
// One past the limit must not land. Otherwise this account's sign-in
// challenge would be longer than everyone else's and say who it is.
let over = env
.state
.db
.add_passkey(id, b"over", STORED_PASSKEY, "One too many", Some(true))
.await
.unwrap();
assert!(over.is_none(), "the limit let an extra passkey through");
let r = admin
.post_json("/api/auth/passkeys/register", &json!({}))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert_eq!(r.json()["code"], "err_passkey_limit");
let listed = begin_named_raw(&Client::new(env.app.clone()), "admin").await;
assert_eq!(listed.len(), server::db::PASSKEY_LIMIT);
}
#[tokio::test]
async fn decoy_credentials_stay_the_same_between_requests() {
let env = Env::new().await;
let _ = env.admin().await;
let c = Client::new(env.app.clone());
// A real account lists stable credential ids. A decoy must too, or two
// probes of one name would tell an attacker it was never real.
let first = c
.post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" }))
.await;
let second = c
.post_json("/api/auth/passkey/login", &json!({ "name": "nobody-here" }))
.await;
let ids = |r: &Resp| -> Vec<String> {
let opts: serde_json::Value =
serde_json::from_str(r.json()["options"].as_str().unwrap()).unwrap();
opts["publicKey"]["allowCredentials"]
.as_array()
.unwrap()
.iter()
.map(|c| c["id"].as_str().unwrap().to_string())
.collect()
};
let ids = (ids(&first), ids(&second));
assert_eq!(ids.0.len(), server::db::PASSKEY_LIMIT);
assert_eq!(ids.0, ids.1, "decoys must not change between requests");
}
#[tokio::test]
async fn a_conditional_challenge_asks_for_autofill_mediation() {
let env = Env::new().await;
let _ = env.admin().await;
let c = Client::new(env.app.clone());
let r = c
.post_json("/api/auth/passkey/login", &json!({ "conditional": true }))
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(
r.json()["options"]
.as_str()
.unwrap()
.contains("conditional")
);
// The button wants the modal picker, so the same route must not ask for
// mediation there.
let r = c.post_json("/api/auth/passkey/login", &json!({})).await;
assert!(
!r.json()["options"]
.as_str()
.unwrap()
.contains("conditional")
);
}
#[tokio::test]
async fn a_handle_cannot_be_answered_twice_or_invented() {
let env = Env::new().await;
let _ = env.admin().await;
let c = Client::new(env.app.clone());
let r = c
.post_json(
"/api/auth/passkey/login/finish",
&json!({ "state_id": "never-issued", "credential": "{}" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
assert_eq!(r.json()["code"], "err_challenge_expired");
// A real handle, then a garbage answer, then the same handle again.
let begin = c
.post_json("/api/auth/passkey/login", &json!({}))
.await
.json();
let state_id = begin["state_id"].as_str().unwrap().to_string();
let body = json!({ "state_id": state_id, "credential": "not json" });
let r = c.post_json("/api/auth/passkey/login/finish", &body).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
let r = c.post_json("/api/auth/passkey/login/finish", &body).await;
assert_eq!(r.json()["code"], "err_challenge_expired");
}
#[tokio::test]
async fn a_registration_handle_cannot_be_used_to_sign_in() {
let env = Env::new().await;
let admin = env.admin().await;
let begin = admin
.post_json("/api/auth/passkeys/register", &json!({}))
.await;
assert_eq!(begin.status, StatusCode::OK, "{}", begin.text());
let state_id = begin.json()["state_id"].as_str().unwrap().to_string();
let c = Client::new(env.app.clone());
let r = c
.post_json(
"/api/auth/passkey/login/finish",
&json!({ "state_id": state_id, "credential": STORED_PASSKEY }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
assert!(session_cookie(&r).is_none());
}
#[tokio::test]
async fn registration_asks_the_authenticator_to_store_the_credential() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin
.post_json("/api/auth/passkeys/register", &json!({}))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let options = r.json()["options"].as_str().unwrap().to_string();
// `webauthn-rs` asks for "discouraged" by default, and password managers
// obey it: every credential would then need the account name typed in
// before it could be found again.
assert!(
options.contains(r#""residentKey":"required""#),
"registration must ask for a discoverable credential: {options}"
);
// User verification too, so a passkey on its own is still two factors.
assert!(
options.contains(r#""userVerification":"required""#),
"{options}"
);
}
#[tokio::test]
async fn every_credential_route_needs_a_session() {
let env = Env::new().await;
let _ = env.admin().await;
let c = Client::new(env.app.clone());
assert_eq!(
c.get("/api/auth/passkeys").await.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
c.post_json(
"/api/auth/password",
&json!({ "new_password": "whatever1" })
)
.await
.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
c.put_json("/api/auth/mode", &json!({ "mode": "either" }))
.await
.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
c.post_json("/api/auth/passkeys/register", &json!({}))
.await
.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
c.delete("/api/auth/password").await.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
c.delete("/api/auth/passkeys/1").await.status,
StatusCode::UNAUTHORIZED
);
assert_eq!(
c.post_json(
"/api/auth/passkeys/register/finish",
&json!({ "state_id": "x", "name": "k", "credential": "{}" })
)
.await
.status,
StatusCode::UNAUTHORIZED
);
}
// ---------------------------------------------------------------------------
// Signing in with the passkey first
// ---------------------------------------------------------------------------
#[tokio::test]
async fn the_passkey_first_order_signs_in_only_with_the_right_password() {
let env = Env::new().await;
let admin = env.admin().await;
let id = user_id(&admin, "admin").await;
give_passkey(&env, id, b"cred-first").await;
admin
.put_json("/api/auth/mode", &json!({ "mode": "both" }))
.await;
let c = Client::new(env.app.clone());
// Stand in for a passkey that already verified. This is the only branch
// that hands out a session without a passkey ceremony in the request, so
// it gets checked directly.
let handle = server::webauthn::put(server::webauthn::Pending::NeedsPassword { user_id: id });
let r = c
.post_json(
"/api/auth/login",
&json!({ "state_id": handle, "password": "wrong-one-1" }),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{}", r.text());
assert!(session_cookie(&r).is_none(), "a wrong password signed in");
// The handle went with that attempt, so it cannot be tried again. That is
// what stops one verified passkey from becoming unlimited password tries.
let r = c
.post_json(
"/api/auth/login",
&json!({ "state_id": handle, "password": "admin1234" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert_eq!(r.json()["code"], "err_challenge_expired");
assert!(session_cookie(&r).is_none());
// A fresh handle and the right password: now it is a session.
let handle = server::webauthn::put(server::webauthn::Pending::NeedsPassword { user_id: id });
let r = c
.post_json(
"/api/auth/login",
&json!({ "state_id": handle, "password": "admin1234" }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(
session_cookie(&r).is_some(),
"the right password got nothing"
);
}
#[tokio::test]
async fn an_invented_login_handle_is_refused() {
let env = Env::new().await;
let admin = env.admin().await;
let c = Client::new(env.app.clone());
let r = c
.post_json(
"/api/auth/login",
&json!({ "state_id": "never-issued", "password": "admin1234" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert!(session_cookie(&r).is_none());
// A registration handle names a user too, but it is not a passed factor.
let begin = admin
.post_json("/api/auth/passkeys/register", &json!({}))
.await;
let handle = begin.json()["state_id"].as_str().unwrap().to_string();
let r = c
.post_json(
"/api/auth/login",
&json!({ "state_id": handle, "password": "admin1234" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert!(session_cookie(&r).is_none());
}
#[tokio::test]
async fn signing_in_works_on_a_host_passkeys_cannot_use() {
let env = Env::new().await;
let _ = env.admin().await;
let c = Client::new(env.app.clone());
// WebAuthn needs a registrable domain, and a bare IP is not one. That must
// cost passkeys only, never the password sign-in every deployment uses.
// `--bind 127.0.0.1` is the default, so this is the normal case.
let r = c
.raw(
Method::POST,
"/api/auth/login",
&[
("content-type", "application/json"),
("host", "192.168.1.10:8080"),
],
json!({ "name": "admin", "password": "admin1234" })
.to_string()
.into_bytes(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(session_cookie(&r).is_some());
}
// ---------------------------------------------------------------------------
// Admin recovery
// ---------------------------------------------------------------------------
#[tokio::test]
async fn an_admin_password_clears_the_passkeys_and_the_requirement() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
let bob_id = user_id(&admin, "bob").await;
give_passkey(&env, bob_id, b"cred-locked").await;
env.state
.db
.set_user_auth_mode(bob_id, api_types::AuthMode::Both)
.await
.unwrap();
// An edit that sets no password leaves bob's credentials alone.
let r = admin
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "active": true }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(env.state.db.count_passkeys(bob_id).await.unwrap(), 1);
// Setting one is the recovery path, and it is the whole recovery: the
// admin cannot hand over a password and leave a second factor bob no
// longer has.
let r = admin
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "password": "rescued12" }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
// Bob is back on a plain password sign-in, with no passkeys left.
let bob = login(&env, "bob", "rescued12").await;
let me = bob.get("/api/auth/me").await.json();
assert_eq!(me["user"]["auth_mode"], "either");
assert_eq!(me["user"]["has_password"], true);
assert_eq!(
bob.get("/api/auth/passkeys").await.json(),
json!([]),
"the passkeys survived the reset"
);
}
#[tokio::test]
async fn deleting_an_account_takes_its_passkeys_with_it() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "bob", "bobpass12", &[("docs", "rw")]).await;
let bob_id = user_id(&admin, "bob").await;
give_passkey(&env, bob_id, b"cred-gone").await;
let r = admin.delete(&format!("/api/admin/users/{bob_id}")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(env.state.db.count_passkeys(bob_id).await.unwrap(), 0);
}
Mserver/tests/common/mod.rs
@@ -179,7 +179,18 @@ impl Client {
extra_headers: &[(&str, &str)],
body: Vec<u8>,
) -> Resp {
// Every real client sends one (HTTP/1.1 requires it), and the
// passkey routes need it to know which domain they speak for.
// `oneshot` with a relative URI would otherwise send none. A caller
// that names its own host wins, so a test can pretend to be on one
// WebAuthn cannot use.
let mut b = axum::http::Request::builder().method(method).uri(path);
if !extra_headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("host"))
{
b = b.header(header::HOST, "files.example.com");
}
for (k, v) in extra_headers {
b = b.header(*k, *v);
}
Mweb/app.css
@@ -2785,3 +2785,92 @@ mark.hl-hit {
.uploads-view .upload-graph svg {
height: 76px;
}
/* Security settings tab: password, passkeys, sign-in requirement. */
.security-tab {
display: flex;
flex-direction: column;
gap: 10px;
}
.security-tab h3 {
margin: 10px 0 0;
}
/* Matches .excludes-field, so the three blocks of this tab read as one list. */
.security-form {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
}
.security-form .field input {
width: 100%;
box-sizing: border-box;
}
.passkey-list {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 14px;
}
.passkey-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.passkey-main {
flex: 1;
overflow: hidden;
}
.passkey-name {
display: block;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* "Needs your user name": a note, not an error. This passkey works, it just
cannot be found without the name typed in first. */
.passkey-warning {
display: inline-block;
margin-top: 2px;
padding: 1px 6px;
border: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
}
/* The sign-in requirement's labels are whole phrases, so the shared 190px
cap for setting selects would clip them. */
.security-tab .setting-row-select select {
max-width: none;
width: auto;
}
/* Separator between the password form and the passkey button. `.auth-card p`
sets a bottom-only margin on every paragraph in the card, and wins on
specificity, so both of these have to name the card too. */
.auth-card .login-or {
margin: 16px 0 8px;
font-size: 12px;
color: var(--muted);
text-transform: lowercase;
}
.auth-card .setting-desc {
margin-top: 8px;
}
.auth-card .btn {
width: 100%;
}
Mweb/src/api.rs
@@ -15,13 +15,16 @@ use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_EXISTS, ACTION_MKDIR,
ACTION_PREVIEW, ACTION_THUMB, ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_LOGIN,
AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare, CreateUser, Credentials, ExistsReq, ExistsResp,
FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH, P_Q, P_ROOT, P_SCOPE, P_SHARE, Root,
SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, Settings, UnlockShare, UpdateUser,
AUTH_LOGOUT, AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER,
AUTH_PASSWORD, AUTH_SETUP, ChangePassword, CreateShare, CreateUser, Credentials, ExistsReq,
ExistsResp, FILES, FINISH_SUFFIX, LoginReq, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_PATH,
P_Q, P_ROOT, P_SCOPE, P_SHARE, PasskeyLoginBegin, PasskeyLoginFinish, PasskeyRegisterFinish,
Root, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES, SetAuthMode, Settings, UnlockShare,
UpdateUser,
};
pub use api_types::{
AdminShare, AdminUser, Entry, Existing, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp,
ShareInfo, UserInfo,
AdminShare, AdminUser, AuthMode, Entry, Existing, FilesResp, LoginResp, Me, Mode, OkResp, Op,
PasskeyChallenge, PasskeyInfo, PasswordStep, RootInfo, SaveResp, ShareInfo, UserInfo,
};
#[derive(Debug, thiserror::Error)]
@@ -126,17 +129,124 @@ pub fn update_profile(
)
}
/// The password leg of signing in.
///
/// `state_id` names a passkey leg that already identified the account, which
/// is how an account requiring both factors finishes when the user starts
/// with the passkey. Then `name` is not needed and is ignored.
pub fn login(
name: String,
name: Option<String>,
password: String,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
state_id: Option<String>,
) -> impl std::future::Future<Output = Result<LoginResp, ApiError>> {
request(
"POST",
AUTH_LOGIN.to_string(),
Some(Credentials { name, password }),
Some(LoginReq {
name,
password,
state_id,
}),
)
}
// ---------------------------------------------------------------------------
// Credentials: password, sign-in mode, passkeys
// ---------------------------------------------------------------------------
pub fn change_password(
new_password: String,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
AUTH_PASSWORD.to_string(),
Some(ChangePassword { new_password }),
)
}
pub fn delete_password() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", AUTH_PASSWORD.to_string(), None::<()>)
}
pub fn set_auth_mode(
mode: AuthMode,
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("PUT", AUTH_MODE.to_string(), Some(SetAuthMode { mode }))
}
pub fn list_passkeys() -> impl std::future::Future<Output = Result<Vec<PasskeyInfo>, ApiError>> {
request("GET", AUTH_PASSKEYS.to_string(), None::<()>)
}
pub fn delete_passkey(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("DELETE", format!("{AUTH_PASSKEYS}/{id}"), None::<()>)
}
/// Register a passkey end to end: ask for a challenge, hand it to the
/// browser, send the answer back.
///
/// One function rather than two calls at the view layer, because the two legs
/// are useless apart and the handle between them is not the view's business.
pub async fn add_passkey(name: String) -> Result<PasskeyInfo, ApiError> {
let challenge: PasskeyChallenge =
request("POST", AUTH_PASSKEYS_REGISTER.to_string(), None::<()>).await?;
let credential = crate::passkey::create(&challenge.options)
.await
.map_err(ApiError::Net)?;
request(
"POST",
format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}"),
Some(PasskeyRegisterFinish {
state_id: challenge.state_id,
name,
credential,
}),
)
.await
}
/// Sign in with a passkey.
///
/// `Ok(None)` means the browser request was cancelled to make room for
/// another one — nothing happened, and nothing should be shown.
pub async fn passkey_login(
name: Option<String>,
conditional: bool,
) -> Result<Option<LoginResp>, ApiError> {
let challenge: PasskeyChallenge = request(
"POST",
AUTH_PASSKEY_LOGIN.to_string(),
Some(PasskeyLoginBegin { name, conditional }),
)
.await?;
passkey_finish(challenge, conditional).await
}
/// Answer a challenge the server already handed out: the second factor of a
/// password sign-in arrives inside the login response, so that leg has no
/// begin call of its own.
pub async fn passkey_finish(
challenge: PasskeyChallenge,
conditional: bool,
) -> Result<Option<LoginResp>, ApiError> {
let Some(credential) = crate::passkey::get(&challenge.options, conditional)
.await
.map_err(ApiError::Net)?
else {
return Ok(None);
};
request(
"POST",
format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}"),
Some(PasskeyLoginFinish {
state_id: challenge.state_id,
credential,
}),
)
.await
.map(Some)
}
pub fn setup(
name: String,
password: String,
@@ -1177,6 +1287,18 @@ pub fn search_stream(
Ok(src)
}
/// Blank an input, so a password does not sit in the DOM waiting for the next
/// person at the keyboard.
pub fn clear_input(id: &str) {
if let Some(el) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id(id))
.and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
{
el.set_value("");
}
}
/// Read a form input's value by element id.
pub fn input_value(id: &str) -> String {
web_sys::window()
Mweb/src/i18n.rs
@@ -186,6 +186,7 @@ i18n_keys! {
ACTIVE_CAN_SIGNIN = "active_can_signin" => "Active (can sign in)",
ADD_FOLDER = "add_folder" => "Add folder…",
ADD_HERE = "add_here" => "Add this folder",
ADD_PASSKEY = "add_passkey" => "Add passkey",
ADMINISTRATOR = "administrator" => "Administrator",
ALL_SHARES = "all_shares" => "All shares",
ALL_SHARES_HINT = "all_shares_hint" => "Every link on this server, grouped by the account that created it.",
@@ -251,6 +252,7 @@ i18n_keys! {
ERR_BAD_POST = "err_bad_post" => "POST expects a multipart upload, a JSON mutation, or ?action=mkdir/create-file",
ERR_BAD_UPLOAD = "err_bad_upload" => "invalid upload data",
ERR_BAD_UPLOAD_PATH = "err_bad_upload_path" => "invalid file path in upload",
ERR_CHALLENGE_EXPIRED = "err_challenge_expired" => "That took too long, please try again.",
ERR_DST_REQUIRED = "err_dst_required" => "dst_root_id is required",
ERR_FILE_NAME_REQUIRED = "err_file_name_required" => "a file name is required",
ERR_FILES_EXIST = "err_files_exist" => "some files already exist",
@@ -267,6 +269,9 @@ i18n_keys! {
ERR_INVALID_LANGUAGE = "err_invalid_language" => "invalid language tag",
ERR_LAST_ADMIN = "err_last_admin" => "cannot remove the last active admin",
ERR_LAST_ADMIN_DELETE = "err_last_admin_delete" => "cannot delete the last active admin",
ERR_LOCKED_OUT = "err_locked_out" => "That would leave the account with no way to sign in.",
ERR_MODE_NEEDS_PASSKEY = "err_mode_needs_passkey" => "Add a passkey before requiring both.",
ERR_MODE_NEEDS_PASSWORD = "err_mode_needs_password" => "Set a password before requiring both.",
ERR_NAME_LENGTH = "err_name_length" => "name must be 1–64 characters",
ERR_NEW_NAME_REQUIRED = "err_new_name_required" => "new_name is required",
ERR_NO_FILES_UPLOADED = "err_no_files_uploaded" => "no files were uploaded",
@@ -277,9 +282,18 @@ i18n_keys! {
ERR_OWN_ADMIN = "err_own_admin" => "you cannot remove your own admin rights",
ERR_OWN_DELETE = "err_own_delete" => "you cannot delete your own account",
ERR_PART_NO_NAME = "err_part_no_name" => "part without a name",
ERR_PASSKEY_DUPLICATE = "err_passkey_duplicate" => "That passkey is already registered.",
ERR_PASSKEY_FAILED = "err_passkey_failed" => "That passkey could not be used.",
ERR_PASSKEY_LAST_CREDENTIAL = "err_passkey_last_credential" => "Set a password before removing your last passkey.",
ERR_PASSKEY_LIMIT = "err_passkey_limit" => "This account already has as many passkeys as it may.",
ERR_PASSKEY_MALFORMED = "err_passkey_malformed" => "The browser sent unreadable data.",
ERR_PASSKEY_NOT_FOUND = "err_passkey_not_found" => "No such passkey.",
ERR_PASSKEY_UNAVAILABLE = "err_passkey_unavailable" => "Passkeys are not available here.",
ERR_PASSWORD_LAST_CREDENTIAL = "err_password_last_credential" => "Add a passkey before removing your password.",
ERR_PASSWORD_SHORT = "err_password_short" => "password must be at least 8 characters",
ERR_PATH_REQUIRED = "err_path_required" => "a path inside the folder is required",
ERR_READ_ONLY_FOLDER = "err_read_only_folder" => "read-only folder",
ERR_REQUIRED_BY_MODE = "err_required_by_mode" => "This account requires a password and a passkey.",
ERR_ROOT_FORBIDDEN = "err_root_forbidden" => "root not accessible",
ERR_RW_RO_FOLDER = "err_rw_ro_folder" => "this folder is read-only for you, so it cannot be shared writably",
ERR_RW_SHARES_DISABLED = "err_rw_shares_disabled" => "writable shares are disabled",
@@ -366,10 +380,14 @@ i18n_keys! {
NEW_FILE = "new_file" => "New file",
NEW_FOLDER = "new_folder" => "New folder",
NEW_NAME = "new_name" => "New name",
NEW_PASSWORD = "new_password" => "New password",
NEW_PASSWORD_HINT = "new_password_hint" => "Leave empty to keep your current password.",
NEW_PASSWORD_KEEP = "new_password_keep" => "New password (leave blank to keep)",
NEW_USER = "new_user" => "New user",
NO_FOLDERS_MSG = "no_folders_msg" => "An administrator hasn't assigned you any folders yet.",
NO_FOLDERS_TITLE = "no_folders_title" => "No folders available",
NO_PASSKEYS = "no_passkeys" => "No passkeys yet.",
NO_PASSWORD_SET = "no_password_set" => "This account signs in with passkeys only.",
NO_PREVIEW = "no_preview" => "No preview available",
NO_PREVIEW_MSG = "no_preview_msg" => "The app can’t display “{}” here.",
NO_SHARES = "no_shares" => "No shares yet",
@@ -378,9 +396,24 @@ i18n_keys! {
OPEN = "open" => "Open",
OPEN_IN_NEW_TAB = "open_in_new_tab" => "Open in new tab",
OPEN_MENU = "open_menu" => "Open menu",
OR_LABEL = "or_label" => "or",
OVERWRITE = "overwrite" => "Overwrite",
OVERWRITE_QUESTION = "overwrite_question" => "The following files already exist. Overwrite them?",
PASSKEY_ADDED = "passkey_added" => "Passkey added",
PASSKEY_ADDED_ON = "passkey_added_on" => "Added {}",
PASSKEY_LABEL = "passkey_label" => "Name for this passkey",
PASSKEY_LAST_USED = "passkey_last_used" => "Last used {}",
PASSKEY_NEEDS_NAME = "passkey_needs_name" => "Needs your user name",
PASSKEY_NEEDS_NAME_HINT = "passkey_needs_name_hint" => "This passkey is not stored on the authenticator itself, so the server has to look it up. Type your user name on the sign-in page before using it.",
PASSKEY_NEVER_USED = "passkey_never_used" => "Never used",
PASSKEY_NOT_USED = "passkey_not_used" => "No passkey was used.",
PASSKEY_REMOVED = "passkey_removed" => "Passkey removed",
PASSKEY_UNSUPPORTED = "passkey_unsupported" => "This browser cannot use passkeys.",
PASSKEYS = "passkeys" => "Passkeys",
PASSKEYS_HINT = "passkeys_hint" => "A passkey signs you in with your fingerprint, your face or a security key.",
PASSWORD = "password" => "Password",
PASSWORD_REMOVED = "password_removed" => "Password removed",
PASSWORD_RESETS_SIGNIN = "password_resets_signin" => "Setting a password also deletes this account's passkeys and drops any two-factor requirement.",
PROFILE = "profile" => "Profile",
PROFILE_SAVE_ERR = "profile_save_err" => "Could not save profile: {}",
PROFILE_SAVED = "profile_saved" => "Profile saved",
@@ -390,6 +423,8 @@ i18n_keys! {
REFRESH = "refresh" => "Refresh",
REFRESH_FOLDERS = "refresh_folders" => "Refresh your folders",
REMOVE_FOLDER = "remove_folder" => "Remove folder",
REMOVE_PASSWORD = "remove_password" => "Remove password",
REMOVE_PASSWORD_DESC = "remove_password_desc" => "Sign in with passkeys only. Needs at least one passkey.",
RENAME = "rename" => "Rename",
RENAME_TITLE = "rename_title" => "Rename “{}”",
RENAMED = "renamed" => "Renamed",
@@ -430,6 +465,9 @@ i18n_keys! {
SEARCH_STOPPED = "search_stopped" => "stopped · {} results",
SEARCH_TOOK = "search_took" => "{} ms",
SEARCHING = "searching" => "searching…",
SECOND_FACTOR_PASSKEY = "second_factor_passkey" => "Now confirm with your passkey.",
SECOND_FACTOR_PASSWORD = "second_factor_password" => "Enter the password for {}.",
SECURITY = "security" => "Security",
SELECT_FOLDER_MSG = "select_folder_msg" => "Choose a folder from the sidebar to start browsing.",
SELECT_FOLDER_TITLE = "select_folder_title" => "Select a folder",
SERVER = "server" => "Server",
@@ -463,6 +501,11 @@ i18n_keys! {
SHARES_EMPTY_HINT = "shares_empty_hint" => "Right-click a file or folder and choose “Share” to create a link.",
SHARES_VIEW_HINT = "shares_view_hint" => "Links you created. Right-click a file or folder and choose “Share” to add one.",
SIGN_IN = "sign_in" => "Sign in",
SIGNIN_BOTH = "signin_both" => "Password and passkey",
SIGNIN_BOTH_DESC = "signin_both_desc" => "Both are required. WebDAV mounts stop working, because they can only send a password.",
SIGNIN_EITHER = "signin_either" => "Password or passkey",
SIGNIN_EITHER_DESC = "signin_either_desc" => "Either one on its own signs you in.",
SIGNIN_REQUIREMENT = "signin_requirement" => "Sign-in requirement",
SINGLE_CLICK_DESC = "single_click_desc" => "When on, a single click opens an entry and Ctrl+click selects it. When off, a single click selects and a double click opens.",
SINGLE_CLICK_LABEL = "single_click_label" => "Use single click to open",
SIZE = "size" => "Size",
@@ -509,6 +552,8 @@ i18n_keys! {
UPLOAD_WAITING = "upload_waiting" => "Waiting for your answer",
UPLOADS = "uploads" => "Uploads",
UPLOADS_EMPTY = "uploads_empty" => "No uploads yet",
USE_PASSKEY = "use_passkey" => "Use a passkey",
USE_PASSKEY_HINT = "use_passkey_hint" => "Leave the name empty unless your passkey needs it.",
USER_CREATED = "user_created" => "User created",
USER_DELETE_ERR = "user_delete_err" => "Could not delete user",
USER_SAVE_ERR = "user_save_err" => "Could not save user: {}",
@@ -522,6 +567,7 @@ const DE: &[(&str, &str)] = &[
("active_can_signin", "Aktiv (kann sich anmelden)"),
("add_folder", "Ordner hinzufügen…"),
("add_here", "Diesen Ordner hinzufügen"),
("add_passkey", "Passkey hinzufügen"),
("administrator", "Administrator"),
("all_shares", "Alle Freigaben"),
(
@@ -632,6 +678,10 @@ const DE: &[(&str, &str)] = &[
),
(k::ERR_BAD_UPLOAD, "ungültige Upload-Daten"),
(k::ERR_BAD_UPLOAD_PATH, "ungültiger Dateipfad im Upload"),
(
k::ERR_CHALLENGE_EXPIRED,
"Das hat zu lange gedauert. Bitte erneut versuchen.",
),
(k::ERR_DST_REQUIRED, "dst_root_id ist erforderlich"),
(k::ERR_FILE_NAME_REQUIRED, "ein Dateiname ist erforderlich"),
(k::ERR_FILES_EXIST, "einige Dateien existieren bereits"),
@@ -666,6 +716,18 @@ const DE: &[(&str, &str)] = &[
k::ERR_LAST_ADMIN_DELETE,
"der letzte aktive Administrator kann nicht gelöscht werden",
),
(
k::ERR_LOCKED_OUT,
"Dann hätte das Konto keine Anmeldemöglichkeit mehr.",
),
(
k::ERR_MODE_NEEDS_PASSKEY,
"Fügen Sie einen Passkey hinzu, bevor Sie beides verlangen.",
),
(
k::ERR_MODE_NEEDS_PASSWORD,
"Setzen Sie ein Passwort, bevor Sie beides verlangen.",
),
(k::ERR_NAME_LENGTH, "der Name muss 1–64 Zeichen haben"),
(k::ERR_NEW_NAME_REQUIRED, "new_name ist erforderlich"),
(k::ERR_NO_FILES_UPLOADED, "keine Dateien hochgeladen"),
@@ -685,6 +747,35 @@ const DE: &[(&str, &str)] = &[
"Sie können Ihr eigenes Konto nicht löschen",
),
(k::ERR_PART_NO_NAME, "Part ohne Namen"),
(
k::ERR_PASSKEY_DUPLICATE,
"Dieser Passkey ist bereits registriert.",
),
(
k::ERR_PASSKEY_FAILED,
"Dieser Passkey konnte nicht verwendet werden.",
),
(
k::ERR_PASSKEY_LAST_CREDENTIAL,
"Setzen Sie ein Passwort, bevor Sie den letzten Passkey entfernen.",
),
(
k::ERR_PASSKEY_LIMIT,
"Dieses Konto hat bereits so viele Passkeys wie erlaubt.",
),
(
k::ERR_PASSKEY_MALFORMED,
"Der Browser hat unlesbare Daten gesendet.",
),
(k::ERR_PASSKEY_NOT_FOUND, "Passkey nicht gefunden."),
(
k::ERR_PASSKEY_UNAVAILABLE,
"Passkeys sind hier nicht verfügbar.",
),
(
k::ERR_PASSWORD_LAST_CREDENTIAL,
"Fügen Sie einen Passkey hinzu, bevor Sie das Passwort entfernen.",
),
(
k::ERR_PASSWORD_SHORT,
"das Passwort muss mindestens 8 Zeichen lang sein",
@@ -694,6 +785,10 @@ const DE: &[(&str, &str)] = &[
"ein Pfad innerhalb des Ordners ist erforderlich",
),
(k::ERR_READ_ONLY_FOLDER, "schreibgeschützter Ordner"),
(
k::ERR_REQUIRED_BY_MODE,
"Dieses Konto verlangt Passwort und Passkey.",
),
(k::ERR_ROOT_FORBIDDEN, "kein Zugriff auf diesen Ordner"),
(
k::ERR_RW_RO_FOLDER,
@@ -831,6 +926,11 @@ const DE: &[(&str, &str)] = &[
("new_file", "Neue Datei"),
("new_folder", "Neuer Ordner"),
("new_name", "Neuer Name"),
("new_password", "Neues Passwort"),
(
"new_password_hint",
"Leer lassen, um das aktuelle Passwort zu behalten.",
),
(
"new_password_keep",
"Neues Passwort (leer lassen, um beizubehalten)",
@@ -841,6 +941,11 @@ const DE: &[(&str, &str)] = &[
"Ein Administrator hat Ihnen noch keine Ordner zugewiesen.",
),
("no_folders_title", "Keine Ordner verfügbar"),
("no_passkeys", "Noch keine Passkeys."),
(
"no_password_set",
"Dieses Konto meldet sich nur mit Passkeys an.",
),
("no_preview", "Keine Vorschau verfügbar"),
("no_preview_msg", "Die App kann „{}“ hier nicht anzeigen."),
("no_shares", "Noch keine Freigaben"),
@@ -849,12 +954,39 @@ const DE: &[(&str, &str)] = &[
("open", "Öffnen"),
("open_in_new_tab", "In neuem Tab öffnen"),
("open_menu", "Menü öffnen"),
("or_label", "oder"),
("overwrite", "Überschreiben"),
(
"overwrite_question",
"Die folgenden Dateien existieren bereits. Überschreiben?",
),
("passkey_added", "Passkey hinzugefügt"),
("passkey_added_on", "Hinzugefügt {}"),
("passkey_label", "Name für diesen Passkey"),
("passkey_last_used", "Zuletzt benutzt {}"),
("passkey_needs_name", "Braucht Ihren Benutzernamen"),
(
"passkey_needs_name_hint",
"Dieser Passkey liegt nicht im Authenticator selbst, der Server muss ihn nachschlagen. Geben Sie Ihren Benutzernamen auf der Anmeldeseite ein, bevor Sie ihn verwenden.",
),
("passkey_never_used", "Nie benutzt"),
("passkey_not_used", "Es wurde kein Passkey verwendet."),
("passkey_removed", "Passkey entfernt"),
(
"passkey_unsupported",
"Dieser Browser kann keine Passkeys verwenden.",
),
("passkeys", "Passkeys"),
(
"passkeys_hint",
"Ein Passkey meldet Sie mit Fingerabdruck, Gesicht oder Sicherheitsschlüssel an.",
),
("password", "Passwort"),
("password_removed", "Passwort entfernt"),
(
"password_resets_signin",
"Ein neues Passwort löscht auch die Passkeys dieses Kontos und hebt eine Zwei-Faktor-Pflicht auf.",
),
("profile", "Profil"),
(
"profile_save_err",
@@ -870,6 +1002,11 @@ const DE: &[(&str, &str)] = &[
("refresh", "Aktualisieren"),
("refresh_folders", "Ordner aktualisieren"),
("remove_folder", "Ordner entfernen"),
("remove_password", "Passwort entfernen"),
(
"remove_password_desc",
"Nur noch mit Passkeys anmelden. Braucht mindestens einen Passkey.",
),
("rename", "Umbenennen"),
("rename_title", "„{}“ umbenennen"),
("renamed", "Umbenannt"),
@@ -919,6 +1056,12 @@ const DE: &[(&str, &str)] = &[
("search_stopped", "gestoppt · {} Treffer"),
("search_took", "{} ms"),
("searching", "Suche läuft…"),
(
"second_factor_passkey",
"Bestätigen Sie jetzt mit Ihrem Passkey.",
),
("second_factor_password", "Passwort für {} eingeben."),
("security", "Sicherheit"),
(
"select_folder_msg",
"Wählen Sie links einen Ordner aus, um mit dem Durchsuchen zu beginnen.",
@@ -991,6 +1134,17 @@ const DE: &[(&str, &str)] = &[
"Von Ihnen erstellte Links. Klicken Sie mit der rechten Maustaste auf eine Datei oder einen Ordner und wählen Sie „Teilen“, um einen hinzuzufügen.",
),
("sign_in", "Anmelden"),
("signin_both", "Passwort und Passkey"),
(
"signin_both_desc",
"Beides ist nötig. WebDAV-Einbindungen funktionieren dann nicht mehr, weil sie nur ein Passwort senden können.",
),
("signin_either", "Passwort oder Passkey"),
(
"signin_either_desc",
"Eines davon allein genügt zur Anmeldung.",
),
("signin_requirement", "Anmeldung verlangt"),
(
"single_click_desc",
"Wenn aktiv, öffnet ein einfacher Klick einen Eintrag und Strg+Klick wählt ihn aus. Wenn inaktiv, wählt ein einfacher Klick aus und ein Doppelklick öffnet.",
@@ -1055,6 +1209,11 @@ const DE: &[(&str, &str)] = &[
("upload_waiting", "Wartet auf Ihre Antwort"),
("uploads", "Uploads"),
("uploads_empty", "Noch keine Uploads"),
("use_passkey", "Passkey verwenden"),
(
"use_passkey_hint",
"Lassen Sie den Namen leer, außer Ihr Passkey braucht ihn.",
),
("user_created", "Benutzer erstellt"),
("user_delete_err", "Benutzer konnte nicht gelöscht werden"),
(
@@ -1071,6 +1230,7 @@ const FR: &[(&str, &str)] = &[
("active_can_signin", "Actif (peut se connecter)"),
("add_folder", "Ajouter un dossier…"),
("add_here", "Ajouter ce dossier"),
("add_passkey", "Ajouter une clé d'accès"),
("administrator", "Administrateur"),
("all_shares", "Tous les partages"),
(
@@ -1190,6 +1350,10 @@ const FR: &[(&str, &str)] = &[
k::ERR_BAD_UPLOAD_PATH,
"chemin de fichier invalide dans le téléversement",
),
(
k::ERR_CHALLENGE_EXPIRED,
"Cela a pris trop de temps. Veuillez réessayer.",
),
(k::ERR_DST_REQUIRED, "dst_root_id est requis"),
(k::ERR_FILE_NAME_REQUIRED, "un nom de fichier est requis"),
(k::ERR_FILES_EXIST, "certains fichiers existent déjà"),
@@ -1215,6 +1379,18 @@ const FR: &[(&str, &str)] = &[
k::ERR_LAST_ADMIN_DELETE,
"impossible de supprimer le dernier administrateur actif",
),
(
k::ERR_LOCKED_OUT,
"Le compte n'aurait alors plus aucun moyen de connexion.",
),
(
k::ERR_MODE_NEEDS_PASSKEY,
"Ajoutez une clé d'accès avant d'exiger les deux.",
),
(
k::ERR_MODE_NEEDS_PASSWORD,
"Définissez un mot de passe avant d'exiger les deux.",
),
(k::ERR_NAME_LENGTH, "le nom doit contenir 1 à 64 caractères"),
(k::ERR_NEW_NAME_REQUIRED, "new_name est requis"),
(k::ERR_NO_FILES_UPLOADED, "aucun fichier n'a été téléversé"),
@@ -1234,12 +1410,45 @@ const FR: &[(&str, &str)] = &[
"vous ne pouvez pas supprimer votre propre compte",
),
(k::ERR_PART_NO_NAME, "partie sans nom"),
(
k::ERR_PASSKEY_DUPLICATE,
"Cette clé d'accès est déjà enregistrée.",
),
(
k::ERR_PASSKEY_FAILED,
"Cette clé d'accès n'a pas pu être utilisée.",
),
(
k::ERR_PASSKEY_LAST_CREDENTIAL,
"Définissez un mot de passe avant de retirer votre dernière clé d'accès.",
),
(
k::ERR_PASSKEY_LIMIT,
"Ce compte a déjà autant de clés d'accès que permis.",
),
(
k::ERR_PASSKEY_MALFORMED,
"Le navigateur a envoyé des données illisibles.",
),
(k::ERR_PASSKEY_NOT_FOUND, "Clé d'accès introuvable."),
(
k::ERR_PASSKEY_UNAVAILABLE,
"Les clés d'accès ne sont pas disponibles ici.",
),
(
k::ERR_PASSWORD_LAST_CREDENTIAL,
"Ajoutez une clé d'accès avant de retirer votre mot de passe.",
),
(
k::ERR_PASSWORD_SHORT,
"le mot de passe doit contenir au moins 8 caractères",
),
(k::ERR_PATH_REQUIRED, "un chemin dans le dossier est requis"),
(k::ERR_READ_ONLY_FOLDER, "dossier en lecture seule"),
(
k::ERR_REQUIRED_BY_MODE,
"Ce compte exige un mot de passe et une clé d'accès.",
),
(k::ERR_ROOT_FORBIDDEN, "dossier inaccessible"),
(
k::ERR_RW_RO_FOLDER,
@@ -1377,6 +1586,11 @@ const FR: &[(&str, &str)] = &[
("new_file", "Nouveau fichier"),
("new_folder", "Nouveau dossier"),
("new_name", "Nouveau nom"),
("new_password", "Nouveau mot de passe"),
(
"new_password_hint",
"Laissez vide pour conserver votre mot de passe actuel.",
),
(
"new_password_keep",
"Nouveau mot de passe (laisser vide pour conserver)",
@@ -1387,6 +1601,11 @@ const FR: &[(&str, &str)] = &[
"Aucun administrateur ne vous a encore attribué de dossier.",
),
("no_folders_title", "Aucun dossier disponible"),
("no_passkeys", "Aucune clé d'accès pour l'instant."),
(
"no_password_set",
"Ce compte se connecte uniquement avec des clés d'accès.",
),
("no_preview", "Aperçu non disponible"),
(
"no_preview_msg",
@@ -1398,12 +1617,39 @@ const FR: &[(&str, &str)] = &[
("open", "Ouvrir"),
("open_in_new_tab", "Ouvrir dans un nouvel onglet"),
("open_menu", "Ouvrir le menu"),
("or_label", "ou"),
("overwrite", "Écraser"),
(
"overwrite_question",
"Les fichiers suivants existent déjà. Les écraser ?",
),
("passkey_added", "Clé d'accès ajoutée"),
("passkey_added_on", "Ajoutée {}"),
("passkey_label", "Nom de cette clé d'accès"),
("passkey_last_used", "Dernière utilisation {}"),
("passkey_needs_name", "Exige votre nom d'utilisateur"),
(
"passkey_needs_name_hint",
"Cette clé d'accès n'est pas stockée dans l'authentificateur, le serveur doit la retrouver. Saisissez votre nom d'utilisateur sur la page de connexion avant de l'utiliser.",
),
("passkey_never_used", "Jamais utilisée"),
("passkey_not_used", "Aucune clé d'accès n'a été utilisée."),
("passkey_removed", "Clé d'accès retirée"),
(
"passkey_unsupported",
"Ce navigateur ne peut pas utiliser de clés d'accès.",
),
("passkeys", "Clés d'accès"),
(
"passkeys_hint",
"Une clé d'accès vous connecte avec votre empreinte, votre visage ou une clé de sécurité.",
),
("password", "Mot de passe"),
("password_removed", "Mot de passe retiré"),
(
"password_resets_signin",
"Définir un mot de passe supprime aussi les clés d'accès de ce compte et lève toute exigence à deux facteurs.",
),
("profile", "Profil"),
(
"profile_save_err",
@@ -1419,6 +1665,11 @@ const FR: &[(&str, &str)] = &[
("refresh", "Actualiser"),
("refresh_folders", "Actualiser vos dossiers"),
("remove_folder", "Retirer le dossier"),
("remove_password", "Retirer le mot de passe"),
(
"remove_password_desc",
"Se connecter uniquement avec des clés d'accès. Exige au moins une clé d'accès.",
),
("rename", "Renommer"),
("rename_title", "Renommer « {} »"),
("renamed", "Renommé"),
@@ -1468,6 +1719,12 @@ const FR: &[(&str, &str)] = &[
("search_stopped", "arrêté · {} résultats"),
("search_took", "{} ms"),
("searching", "recherche…"),
(
"second_factor_passkey",
"Confirmez maintenant avec votre clé d'accès.",
),
("second_factor_password", "Saisissez le mot de passe de {}."),
("security", "Sécurité"),
(
"select_folder_msg",
"Choisissez un dossier dans le panneau latéral pour commencer à naviguer.",
@@ -1537,6 +1794,17 @@ const FR: &[(&str, &str)] = &[
"Liens que vous avez créés. Faites un clic droit sur un fichier ou un dossier, puis choisissez « Partager » pour en ajouter un.",
),
("sign_in", "Se connecter"),
("signin_both", "Mot de passe et clé d'accès"),
(
"signin_both_desc",
"Les deux sont exigés. Les montages WebDAV cessent de fonctionner, car ils ne peuvent envoyer qu'un mot de passe.",
),
("signin_either", "Mot de passe ou clé d'accès"),
(
"signin_either_desc",
"L'un ou l'autre suffit pour se connecter.",
),
("signin_requirement", "Exigence de connexion"),
(
"single_click_desc",
"Quand activé, un simple clic ouvre une entrée et Ctrl+clic la sélectionne. Quand désactivé, un simple clic sélectionne et un double-clic ouvre.",
@@ -1601,6 +1869,11 @@ const FR: &[(&str, &str)] = &[
("upload_waiting", "En attente de votre réponse"),
("uploads", "Téléversements"),
("uploads_empty", "Aucun téléversement pour l'instant"),
("use_passkey", "Utiliser une clé d'accès"),
(
"use_passkey_hint",
"Laissez le nom vide, sauf si votre clé d'accès en a besoin.",
),
("user_created", "Utilisateur créé"),
("user_delete_err", "Impossible de supprimer l'utilisateur"),
(
Mweb/src/main.rs
@@ -9,6 +9,7 @@ mod components;
mod editor;
mod i18n;
mod icons;
mod passkey;
mod preview;
mod router;
mod theme;
Aweb/src/passkey.rs
@@ -0,0 +1,130 @@
//! The browser half of WebAuthn.
//!
//! `navigator.credentials` deals in `ArrayBuffer`s, and the wire format is
//! base64url. The platform converts between the two itself —
//! `parseCreationOptionsFromJSON` on the way in, `toJSON()` on the way out —
//! so this module is a thin shim rather than an encoder. `web-sys` also has
//! WebAuthn bindings, but only behind `--cfg web_sys_unstable_apis`, which
//! would infect the whole build.
use wasm_bindgen::prelude::*;
#[wasm_bindgen(inline_js = r#"
// One outstanding navigator.credentials.get(), at most. A conditional
// request sits in the autofill dropdown until the user touches the field, so
// pressing the button has to cancel it before starting its own.
let pending = null;
export function passkeySupported() {
return typeof window.PublicKeyCredential === "function"
&& typeof PublicKeyCredential.parseRequestOptionsFromJSON === "function"
&& typeof PublicKeyCredential.parseCreationOptionsFromJSON === "function";
}
export async function conditionalSupported() {
if (!passkeySupported()) return false;
if (typeof PublicKeyCredential.isConditionalMediationAvailable !== "function") return false;
try {
return await PublicKeyCredential.isConditionalMediationAvailable();
} catch (e) {
return false;
}
}
export function passkeyCancel() {
if (pending) { pending.abort(); pending = null; }
}
export async function passkeyCreate(optionsJson) {
const opts = PublicKeyCredential.parseCreationOptionsFromJSON(
JSON.parse(optionsJson).publicKey
);
const cred = await navigator.credentials.create({ publicKey: opts });
if (!cred) throw new Error("no credential was created");
return JSON.stringify(cred.toJSON());
}
// Resolves to the credential JSON, or to null when the request was cancelled
// to make room for another one. A cancellation is not a failure and must not
// reach the user.
export async function passkeyGet(optionsJson, conditional) {
passkeyCancel();
const opts = PublicKeyCredential.parseRequestOptionsFromJSON(
JSON.parse(optionsJson).publicKey
);
const ctl = new AbortController();
pending = ctl;
try {
const req = { publicKey: opts, signal: ctl.signal };
if (conditional) req.mediation = "conditional";
const cred = await navigator.credentials.get(req);
if (!cred) throw new Error("no credential was returned");
const json = cred.toJSON();
// The server's parser wants the key present even when it is null, and
// not every browser includes it for a non-discoverable credential.
if (json.response && !("userHandle" in json.response)) {
json.response.userHandle = null;
}
return JSON.stringify(json);
} catch (e) {
if (e && e.name === "AbortError") return null;
throw e;
} finally {
if (pending === ctl) pending = null;
}
}
"#)]
extern "C" {
#[wasm_bindgen(js_name = passkeySupported)]
fn js_supported() -> bool;
#[wasm_bindgen(js_name = conditionalSupported)]
async fn js_conditional_supported() -> JsValue;
#[wasm_bindgen(js_name = passkeyCancel)]
pub fn cancel();
#[wasm_bindgen(js_name = passkeyCreate, catch)]
async fn js_create(options: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(js_name = passkeyGet, catch)]
async fn js_get(options: &str, conditional: bool) -> Result<JsValue, JsValue>;
}
/// Whether this browser can do WebAuthn at all. False hides every passkey
/// control rather than offering one that cannot work.
pub fn supported() -> bool {
js_supported()
}
/// Whether this browser offers passkeys in the autofill dropdown.
pub async fn conditional_supported() -> bool {
js_conditional_supported().await.as_bool().unwrap_or(false)
}
/// Register a new credential. `options` is the server's challenge JSON.
pub async fn create(options: &str) -> Result<String, String> {
js_create(options)
.await
.map_err(error_text)?
.as_string()
.ok_or_else(|| "the browser returned nothing".to_string())
}
/// Ask for an assertion. `Ok(None)` means the request was cancelled to make
/// room for another one, which is not something the user needs to hear about.
pub async fn get(options: &str, conditional: bool) -> Result<Option<String>, String> {
Ok(js_get(options, conditional)
.await
.map_err(error_text)?
.as_string())
}
/// One neutral message for every WebAuthn failure.
///
/// The API deliberately returns the same `NotAllowedError` whether the user
/// cancelled or nothing matched, so there is nothing more specific to say.
/// Claiming "you have no passkey here" would often be wrong.
fn error_text(_e: JsValue) -> String {
crate::i18n::t(crate::i18n::k::PASSKEY_NOT_USED).to_string()
}
Mweb/src/views/admin.rs
@@ -18,10 +18,12 @@ use crate::views::dialogs::{Dialog, admin_root};
// Settings view
// ---------------------------------------------------------------------------
/// The two settings tabs. Every user sees Profile; Server is admin-only.
/// The settings tabs. Every user sees Profile and Security; Server is
/// admin-only.
#[derive(Clone, Copy, PartialEq)]
enum SettingsTab {
Profile,
Security,
Server,
}
@@ -32,6 +34,7 @@ impl SettingsTab {
match crate::util::storage_get(Self::KEY).as_deref() {
Some("server") if is_admin => SettingsTab::Server,
Some("profile") => SettingsTab::Profile,
Some("security") => SettingsTab::Security,
// First visit: admins land on the server tab (the pre-existing
// default), everyone else on their profile.
_ if is_admin => SettingsTab::Server,
@@ -44,6 +47,7 @@ impl SettingsTab {
Self::KEY,
match self {
SettingsTab::Server => "server",
SettingsTab::Security => "security",
SettingsTab::Profile => "profile",
},
);
@@ -262,6 +266,13 @@ pub fn SettingsView(
>
{i18n::tr(i18n::k::PROFILE)}
</button>
<button
class="settings-tab-btn"
class:active=move || tab.get() == SettingsTab::Security
on:click=move |_| pick_tab(SettingsTab::Security)
>
{i18n::tr(i18n::k::SECURITY)}
</button>
<Show when=move || is_admin()>
<button
class="settings-tab-btn"
@@ -385,6 +396,9 @@ pub fn SettingsView(
.into_any(),
}}
</Show>
<Show when=move || tab.get() == SettingsTab::Security>
<crate::views::security::SecurityView me=me set_me=set_me/>
</Show>
<Show when=move || tab.get() == SettingsTab::Server && is_admin()>
<SettingToggle
label=i18n::t(i18n::k::ALLOW_RW_SHARES)
@@ -875,6 +889,12 @@ fn UserForm(
}
/>
</label>
{(!is_new)
.then(|| {
view! {
<p class="setting-desc">{i18n::tr(i18n::k::PASSWORD_RESETS_SIGNIN)}</p>
}
})}
<label class="check-row">
<input
type="checkbox"
Mweb/src/views/login.rs
@@ -2,10 +2,26 @@ use crate::i18n;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me, input_value};
use crate::api::{self, LoginResp, Me, PasswordStep, input_value};
use crate::app::AuthPhase;
use crate::components::logo::Logo;
/// Where a sign-in currently stands.
///
/// An account that needs a password *and* a passkey can start with either
/// one, so both halves are a step the form can be parked on. Which half is
/// missing is the server's answer, never a guess here.
#[derive(Clone)]
enum Step {
/// Name and password, plus the passkey button.
Start,
/// The password passed; the passkey is still to come. Nothing to fill in,
/// the browser's own dialog is already open.
NeedPasskey,
/// A passkey passed; this account's password is still to come.
NeedPassword(PasswordStep),
}
#[component]
pub fn LoginView(
set_me: WriteSignal<Option<Me>>,
@@ -13,33 +29,91 @@ pub fn LoginView(
) -> impl IntoView {
let (error, set_error) = signal(String::new());
let (busy, set_busy) = signal(false);
let (step, set_step) = signal(Step::Start);
// Both legs can produce any of the three answers, so one place applies
// them.
let apply = move |resp: LoginResp| {
if let Some(challenge) = resp.passkey_challenge {
set_step.set(Step::NeedPasskey);
spawn_local(async move {
match api::passkey_finish(challenge, false).await {
// The browser request was cancelled to make room for
// another one. Nothing happened, so say nothing.
Ok(None) => {}
Ok(Some(next)) if next.ok => finish_session(set_me, set_phase, set_error),
Ok(Some(_)) => set_error.set(i18n::t(i18n::k::PASSKEY_NOT_USED).into()),
Err(e) => set_error.set(e.to_string()),
}
set_busy.set(false);
set_step.set(Step::Start);
});
return;
}
if let Some(pending) = resp.password_required {
set_step.set(Step::NeedPassword(pending));
set_busy.set(false);
return;
}
if resp.ok {
finish_session(set_me, set_phase, set_error);
}
set_busy.set(false);
};
let on_submit = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
let name = input_value("login-name");
let pass = input_value("login-pass");
if name.trim().is_empty() || pass.is_empty() {
// The second password step already knows the account, so the name
// field is not on screen and not needed.
let (name, state_id) = match step.get() {
Step::NeedPassword(p) => (None, Some(p.state_id)),
_ => {
let n = input_value("login-name").trim().to_string();
if n.is_empty() {
set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into());
return;
}
(Some(n), None)
}
};
if pass.is_empty() {
set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into());
return;
}
set_error.set(String::new());
set_busy.set(true);
let name = name.trim().to_string();
spawn_local(async move {
match api::login(name, pass).await {
Ok(_) => match api::me().await {
Ok(m) => {
// Keep the current hash: deep links (e.g. #/users) survive
// the sign-in. A non-admin landing on an admin section is
// redirected to the files view by the shell instead.
set_me.set(Some(m));
set_phase.set(AuthPhase::Authed);
}
Err(e) => {
set_error.set(e.to_string());
set_busy.set(false);
match api::login(name, pass, state_id).await {
Ok(resp) => apply(resp),
Err(e) => {
set_error.set(e.to_string());
set_busy.set(false);
// The handle was spent on the way in, whether or not the
// password was right. Retrying on this step would send a
// dead one forever, so go back to the start.
if matches!(step.get_untracked(), Step::NeedPassword(_)) {
set_step.set(Step::Start);
}
},
}
}
});
};
// The name goes along only if one was typed: without it the server issues
// a discoverable challenge, which is what lets a passkey sign in with an
// empty form.
let on_passkey = move |_| {
if busy.get() {
return;
}
let name = input_value("login-name").trim().to_string();
set_error.set(String::new());
set_busy.set(true);
spawn_local(async move {
match api::passkey_login((!name.is_empty()).then_some(name), false).await {
Ok(None) => set_busy.set(false),
Ok(Some(resp)) => apply(resp),
Err(e) => {
set_error.set(e.to_string());
set_busy.set(false);
@@ -48,36 +122,105 @@ pub fn LoginView(
});
};
// Conditional mediation: the passkey offers itself in the name field's
// autofill instead of behind a button. The request stays pending until
// the user picks one, so it must not touch `busy` — the form has to keep
// working while it waits.
let supported = crate::passkey::supported();
if supported {
spawn_local(async move {
if !crate::passkey::conditional_supported().await {
return;
}
match api::passkey_login(None, true).await {
Ok(Some(resp)) if resp.ok => finish_session(set_me, set_phase, set_error),
Ok(Some(resp)) => {
set_busy.set(true);
apply(resp);
}
// Cancelled, or the page is going away. Either way silent.
Ok(None) | Err(_) => {}
}
});
}
// Leaving the page with a conditional request still parked would block
// the next `navigator.credentials.get`.
on_cleanup(crate::passkey::cancel);
view! {
<div class="center-screen">
<div class="card auth-card">
<Logo class="auth-logo".to_string()/>
<h1>"filebrowser-ng"</h1>
<p class="muted">{i18n::tr(i18n::k::LOGIN_SUBTITLE)}</p>
<p class="muted">
{move || match step.get() {
Step::NeedPasskey => i18n::t(i18n::k::SECOND_FACTOR_PASSKEY).to_string(),
Step::NeedPassword(p) => {
i18n::t_fmt(i18n::k::SECOND_FACTOR_PASSWORD, &p.name)
}
Step::Start => i18n::t(i18n::k::LOGIN_SUBTITLE).to_string(),
}}
</p>
<form on:submit=on_submit>
<label class="field">
<span>{i18n::tr(i18n::k::NAME)}</span>
<input
id="login-name"
type="text"
autofocus=true
autocomplete="username"
/>
</label>
<label class="field">
<span>{i18n::tr(i18n::k::PASSWORD)}</span>
<input
id="login-pass"
type="password"
autocomplete="current-password"
/>
</label>
<Show when=move || matches!(step.get(), Step::Start)>
<label class="field">
<span>{i18n::tr(i18n::k::NAME)}</span>
<input
id="login-name"
type="text"
autofocus=true
// `webauthn` is what puts passkeys into this
// field's autofill dropdown.
autocomplete="username webauthn"
/>
</label>
</Show>
<Show when=move || !matches!(step.get(), Step::NeedPasskey)>
<label class="field">
<span>{i18n::tr(i18n::k::PASSWORD)}</span>
<input
id="login-pass"
type="password"
autocomplete="current-password"
/>
</label>
</Show>
<p class="error">{move || error.get()}</p>
<button class="btn-primary" type="submit" disabled=move || busy.get()>
{i18n::tr(i18n::k::SIGN_IN)}
</button>
<Show when=move || !matches!(step.get(), Step::NeedPasskey)>
<button class="btn-primary" type="submit" disabled=move || busy.get()>
{i18n::tr(i18n::k::SIGN_IN)}
</button>
</Show>
</form>
<Show when=move || supported && matches!(step.get(), Step::Start)>
<p class="login-or">{i18n::tr(i18n::k::OR_LABEL)}</p>
<button class="btn" disabled=move || busy.get() on:click=on_passkey>
{i18n::tr(i18n::k::USE_PASSKEY)}
</button>
<p class="setting-desc">{i18n::tr(i18n::k::USE_PASSKEY_HINT)}</p>
</Show>
</div>
</div>
}
}
/// Load `/me` and hand the app over to the signed-in shell.
///
/// The current hash is kept, so a deep link (e.g. `#/users`) survives the
/// sign-in. A non-admin landing on an admin section is redirected to the
/// files view by the shell instead.
fn finish_session(
set_me: WriteSignal<Option<Me>>,
set_phase: WriteSignal<AuthPhase>,
set_error: WriteSignal<String>,
) {
spawn_local(async move {
match api::me().await {
Ok(m) => {
set_me.set(Some(m));
set_phase.set(AuthPhase::Authed);
}
Err(e) => set_error.set(e.to_string()),
}
});
}
Mweb/src/views/mod.rs
@@ -4,6 +4,7 @@ pub mod dialogs;
pub mod file_view;
pub mod login;
pub mod search;
pub mod security;
pub mod setup;
pub mod share_page;
pub mod shares;
Aweb/src/views/security.rs
@@ -0,0 +1,336 @@
//! The Security tab of profile settings: the password, the passkeys, and
//! which of the two this account needs to sign in.
//!
//! Every rule that keeps an account reachable lives on the server. This view
//! only hides controls that cannot work — a browser without WebAuthn, a
//! `Remove password` button on an account with no passkeys — so the reason
//! for a refusal arrives as the server's own message rather than two
//! implementations of the same rule drifting apart.
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AuthMode, Me, PasskeyInfo, input_value};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
use crate::util::select_value;
/// Trim an RFC 3339 timestamp to its date. The exact minute a passkey was
/// registered is noise in a list.
fn day(ts: &str) -> String {
ts.split('T').next().unwrap_or(ts).to_string()
}
#[component]
pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let user = move || me.get().and_then(|m| m.user);
let has_password = move || user().is_some_and(|u| u.has_password);
let saved_mode = move || user().map(|u| u.auth_mode).unwrap_or_default();
let (passkeys, set_passkeys) = signal(Option::<Vec<PasskeyInfo>>::None);
let (busy, set_busy) = signal(false);
let (error, set_error) = signal(Option::<String>::None);
// The select is a draft until Save. Changing a sign-in requirement by
// brushing a dropdown would be the one change on this page nobody asked
// for.
let (mode_draft, set_mode_draft) = signal(Option::<AuthMode>::None);
let mode = move || mode_draft.get().unwrap_or_else(saved_mode);
let reload = move || {
spawn_local(async move {
match api::list_passkeys().await {
Ok(list) => set_passkeys.set(Some(list)),
Err(e) => set_passkeys.set({
show_error(toast, e.to_string());
Some(Vec::new())
}),
}
});
};
Effect::new(move |_| reload());
// Re-reading `/me` after a credential change keeps the rest of the app
// (and this view's own buttons) in step with what the account now needs.
let refresh_me = move || {
spawn_local(async move {
if let Ok(m) = api::me().await {
set_me.set(Some(m));
}
});
};
// --- password and sign-in requirement, in one save --------------------
let save = move |_| {
if busy.get() {
return;
}
set_error.set(None);
let next = input_value("sec-new");
let repeat = input_value("sec-repeat");
// An empty new password means "leave it alone", so the repeat box
// only matters once something was typed.
if !next.is_empty() {
if next != repeat {
return set_error.set(Some(i18n::t(i18n::k::SETUP_PW_MISMATCH).to_string()));
}
if next.len() < 8 {
return set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
}
}
let wanted = mode_draft.get().filter(|m| *m != saved_mode());
if next.is_empty() && wanted.is_none() {
return;
}
set_busy.set(true);
spawn_local(async move {
// The password first: switching to "both" needs one to exist, so
// setting a first password and requiring both can be one save.
let mut failed = false;
if !next.is_empty() {
match api::change_password(next).await {
Ok(_) => clear_fields(&["sec-new", "sec-repeat"]),
Err(e) => {
set_error.set(Some(e.to_string()));
failed = true;
}
}
}
if !failed
&& let Some(m) = wanted
&& let Err(e) = api::set_auth_mode(m).await
{
set_error.set(Some(e.to_string()));
failed = true;
}
if !failed {
show(toast, i18n::t(i18n::k::SAVED).to_string());
}
// Either way `/me` is now the truth: a partial save must not
// leave the form claiming something else.
set_mode_draft.set(None);
refresh_me();
set_busy.set(false);
});
};
let remove_password = move |_| {
if busy.get() {
return;
}
set_error.set(None);
set_busy.set(true);
spawn_local(async move {
match api::delete_password().await {
Ok(_) => {
show(toast, i18n::t(i18n::k::PASSWORD_REMOVED).to_string());
clear_fields(&["sec-new", "sec-repeat"]);
refresh_me();
}
Err(e) => set_error.set(Some(e.to_string())),
}
set_busy.set(false);
});
};
// --- passkeys ---------------------------------------------------------
let add_passkey = move |_| {
if busy.get() {
return;
}
set_error.set(None);
let label = input_value("sec-passkey-name");
set_busy.set(true);
spawn_local(async move {
match api::add_passkey(label).await {
Ok(_) => {
show(toast, i18n::t(i18n::k::PASSKEY_ADDED).to_string());
clear_fields(&["sec-passkey-name"]);
reload();
refresh_me();
}
Err(e) => set_error.set(Some(e.to_string())),
}
set_busy.set(false);
});
};
let remove_passkey = Callback::new(move |id: i64| {
set_error.set(None);
spawn_local(async move {
match api::delete_passkey(id).await {
Ok(_) => {
show(toast, i18n::t(i18n::k::PASSKEY_REMOVED).to_string());
reload();
refresh_me();
}
Err(e) => set_error.set(Some(e.to_string())),
}
});
});
let supported = crate::passkey::supported();
view! {
<div class="security-tab">
{move || {
let Some(e) = error.get() else {
return view! {}.into_any();
};
view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
}}
<h3 class="setting-label">{i18n::tr(i18n::k::PASSWORD)}</h3>
<Show when=move || !has_password()>
<p class="muted">{i18n::tr(i18n::k::NO_PASSWORD_SET)}</p>
</Show>
<div class="security-form">
<label class="field">
<span>{i18n::tr(i18n::k::NEW_PASSWORD)}</span>
<input id="sec-new" type="password" autocomplete="new-password"/>
<Show when=move || has_password()>
<span class="setting-desc">{i18n::tr(i18n::k::NEW_PASSWORD_HINT)}</span>
</Show>
</label>
<label class="field">
<span>{i18n::tr(i18n::k::REPEAT_PASSWORD)}</span>
<input id="sec-repeat" type="password" autocomplete="new-password"/>
</label>
</div>
<h3 class="setting-label">{i18n::tr(i18n::k::SIGNIN_REQUIREMENT)}</h3>
<div class="setting-row setting-row-select">
<span>
<span class="setting-desc">
{move || if mode() == AuthMode::Both {
i18n::t(i18n::k::SIGNIN_BOTH_DESC)
} else {
i18n::t(i18n::k::SIGNIN_EITHER_DESC)
}}
</span>
</span>
<select
prop:value=move || mode().as_str()
on:change=move |ev: web_sys::Event| {
if let Some(m) = select_value(&ev).and_then(|v| AuthMode::from_wire(&v)) {
set_mode_draft.set(Some(m));
}
}
>
<option value="either">{i18n::tr(i18n::k::SIGNIN_EITHER)}</option>
<option value="both">{i18n::tr(i18n::k::SIGNIN_BOTH)}</option>
</select>
</div>
<div class="modal-actions">
// Only offered when it can succeed: the account needs a
// password to drop and a passkey to fall back on.
<Show when=move || {
has_password()
&& saved_mode() == AuthMode::Either
&& passkeys.get().is_some_and(|p| !p.is_empty())
}>
<button
class="btn btn-danger"
disabled=move || busy.get()
title=i18n::t(i18n::k::REMOVE_PASSWORD_DESC)
on:click=remove_password
>
{i18n::tr(i18n::k::REMOVE_PASSWORD)}
</button>
</Show>
<button class="btn btn-primary" disabled=move || busy.get() on:click=save>
{move || if busy.get() {
i18n::t(i18n::k::SAVING).to_string()
} else {
i18n::t(i18n::k::SAVE).to_string()
}}
</button>
</div>
<h3 class="setting-label">{i18n::tr(i18n::k::PASSKEYS)}</h3>
<p class="setting-desc">{i18n::tr(i18n::k::PASSKEYS_HINT)}</p>
<Show
when=move || supported
fallback=|| view! { <p class="muted">{i18n::tr(i18n::k::PASSKEY_UNSUPPORTED)}</p> }
>
<div class="passkey-list">
{move || match passkeys.get() {
None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
.into_view()
.into_any(),
Some(list) if list.is_empty() => {
view! { <p class="muted">{i18n::tr(i18n::k::NO_PASSKEYS)}</p> }
.into_view()
.into_any()
}
Some(list) => list
.into_iter()
.map(|p| view! { <PasskeyRow info=p on_remove=remove_passkey/> })
.collect::<Vec<_>>()
.into_view()
.into_any(),
}}
</div>
<div class="security-form">
<label class="field">
<span>{i18n::tr(i18n::k::PASSKEY_LABEL)}</span>
<input id="sec-passkey-name" type="text" autocomplete="off"/>
</label>
<div class="modal-actions">
<button class="btn" disabled=move || busy.get() on:click=add_passkey>
<Icon name=IconName::Add class="ic-btn".to_string()/>
{i18n::tr(i18n::k::ADD_PASSKEY)}
</button>
</div>
</div>
</Show>
</div>
}
}
/// One passkey in the list.
///
/// `discoverable == Some(false)` is the only case that earns a warning. The
/// browser reports this through an optional, unsigned extension, so `None`
/// means it did not say, and guessing "not discoverable" there would put a
/// scary label on a passkey that works perfectly.
#[component]
fn PasskeyRow(info: PasskeyInfo, on_remove: Callback<i64>) -> impl IntoView {
let id = info.id;
let used = match &info.last_used_at {
Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)),
None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
};
let needs_name = info.discoverable == Some(false);
view! {
<div class="passkey-row">
<Icon name=IconName::User class="ic-row".to_string()/>
<span class="passkey-main">
<span class="passkey-name">{info.name.clone()}</span>
<span class="setting-desc">
{i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&info.created_at))}
" · " {used}
</span>
<Show when=move || needs_name>
<span class="passkey-warning" title=i18n::t(i18n::k::PASSKEY_NEEDS_NAME_HINT)>
{i18n::tr(i18n::k::PASSKEY_NEEDS_NAME)}
</span>
</Show>
</span>
<button
class="icon-btn"
title=i18n::t(i18n::k::DELETE)
aria-label=i18n::t(i18n::k::DELETE)
on:click=move |_| on_remove.run(id)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</div>
}
}
fn clear_fields(ids: &[&str]) {
ids.iter().for_each(|id| api::clear_input(id));
}