feat(auth): replace password auth with WebAuthn passkeys
Replace username/password authentication with FIDO2/WebAuthn passkey-based auth using webauthn-rs. Sessions and bearer tokens are unchanged — only the way they are created changes. - Add webauthn-rs, uuid, open, url deps; remove argon2, rpassword - Add passkey_credentials and registration_tokens tables (migrations 17-18) - Add domain entities, typed IDs, and repository traits for passkeys/tokens - Add SQL repository implementations for passkeys and registration tokens - Add ChallengeStore for in-memory WebAuthn ceremony state - Add WebAuthn route handlers (register/auth start+finish ceremonies) - Add CLI browser handoff for token creation (opens browser, local callback) - Replace login form with "Sign in with Passkey" button - Add registration page for first-user bootstrap via one-time token - Replace BREWLOG_ADMIN_USERNAME/PASSWORD with BREWLOG_RP_ID/RP_ORIGIN - Change default BREWLOG_URL from 127.0.0.1 to localhost (WebAuthn requires it)
This commit is contained in:
parent
8c521f0e05
commit
03e03d87d9
31 changed files with 2038 additions and 480 deletions
494
Cargo.lock
generated
494
Cargo.lock
generated
|
|
@ -101,18 +101,6 @@ version = "1.0.100"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "argon2"
|
|
||||||
version = "0.5.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
"blake2",
|
|
||||||
"cpufeatures",
|
|
||||||
"password-hash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "askama"
|
name = "askama"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -157,6 +145,45 @@ dependencies = [
|
||||||
"nom",
|
"nom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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.110",
|
||||||
|
"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.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
|
|
@ -179,6 +206,28 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-stream"
|
||||||
|
version = "0.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||||
|
dependencies = [
|
||||||
|
"async-stream-impl",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-stream-impl"
|
||||||
|
version = "0.3.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
|
|
@ -296,6 +345,17 @@ version = "1.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64urlsafedata"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"pastey",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "basic-toml"
|
name = "basic-toml"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
|
|
@ -305,6 +365,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
|
|
@ -314,15 +380,6 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "blake2"
|
|
||||||
version = "0.10.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
|
||||||
dependencies = [
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -337,7 +394,6 @@ name = "brewlog"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
|
||||||
"askama",
|
"askama",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -347,10 +403,10 @@ dependencies = [
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"isocountry",
|
"isocountry",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"open",
|
||||||
"portpicker",
|
"portpicker",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rpassword",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|
@ -365,6 +421,11 @@ dependencies = [
|
||||||
"tracing-bunyan-formatter",
|
"tracing-bunyan-formatter",
|
||||||
"tracing-log 0.2.0",
|
"tracing-log 0.2.0",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
"webauthn-authenticator-rs",
|
||||||
|
"webauthn-rs",
|
||||||
|
"webauthn-rs-proto",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -574,6 +635,12 @@ version = "0.8.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -584,6 +651,12 @@ dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deadpool"
|
name = "deadpool"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
|
|
@ -613,6 +686,20 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der-parser"
|
||||||
|
version = "9.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"displaydoc",
|
||||||
|
"nom",
|
||||||
|
"num-bigint",
|
||||||
|
"num-traits",
|
||||||
|
"rusticata-macros",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
|
|
@ -747,6 +834,21 @@ version = "1.0.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -922,6 +1024,17 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "half"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crunchy",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
|
|
@ -1272,6 +1385,25 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-docker"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-wsl"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||||
|
dependencies = [
|
||||||
|
"is-docker",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
|
|
@ -1331,7 +1463,7 @@ version = "0.1.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.10.0",
|
||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall",
|
||||||
]
|
]
|
||||||
|
|
@ -1479,6 +1611,16 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
|
|
@ -1501,6 +1643,17 @@ version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
|
|
@ -1541,6 +1694,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oid-registry"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
|
|
@ -1553,6 +1715,55 @@ version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "open"
|
||||||
|
version = "5.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||||
|
dependencies = [
|
||||||
|
"is-wsl",
|
||||||
|
"libc",
|
||||||
|
"pathdiff",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.111"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
|
|
@ -1576,23 +1787,24 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "password-hash"
|
|
||||||
version = "0.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
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"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -1840,7 +2052,7 @@ version = "0.5.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.10.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1930,17 +2142,6 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rpassword"
|
|
||||||
version = "7.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"rtoolbox",
|
|
||||||
"windows-sys 0.59.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.9"
|
version = "0.9.9"
|
||||||
|
|
@ -1961,29 +2162,28 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rtoolbox"
|
|
||||||
version = "0.0.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusticata-macros"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.10.0",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
|
|
@ -2093,6 +2293,26 @@ dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_bytes"
|
||||||
|
version = "0.11.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_cbor_2"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
|
||||||
|
dependencies = [
|
||||||
|
"half",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -2375,7 +2595,7 @@ checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"bitflags",
|
"bitflags 2.10.0",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -2418,7 +2638,7 @@ checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"bitflags",
|
"bitflags 2.10.0",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
|
|
@ -2708,6 +2928,7 @@ dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2773,7 +2994,7 @@ version = "0.6.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.10.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
|
|
@ -2799,9 +3020,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.41"
|
version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
|
@ -2811,9 +3032,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.30"
|
version = "0.1.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -2840,9 +3061,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.34"
|
version = "0.1.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"valuable",
|
"valuable",
|
||||||
|
|
@ -2981,6 +3202,18 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uuid"
|
||||||
|
version = "1.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"js-sys",
|
||||||
|
"serde_core",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -3107,6 +3340,107 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn-attestation-ca"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b"
|
||||||
|
dependencies = [
|
||||||
|
"base64urlsafedata",
|
||||||
|
"openssl",
|
||||||
|
"openssl-sys",
|
||||||
|
"serde",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn-authenticator-rs"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78b41ed08aba475a969094226ae0691a286686210ae497bb2c5d0ed722d8d526"
|
||||||
|
dependencies = [
|
||||||
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.21.7",
|
||||||
|
"base64urlsafedata",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"futures",
|
||||||
|
"hex",
|
||||||
|
"nom",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"openssl",
|
||||||
|
"openssl-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_bytes",
|
||||||
|
"serde_cbor_2",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tracing",
|
||||||
|
"unicode-normalization",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
"webauthn-rs-core",
|
||||||
|
"webauthn-rs-proto",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn-rs"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8"
|
||||||
|
dependencies = [
|
||||||
|
"base64urlsafedata",
|
||||||
|
"serde",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
|
"webauthn-rs-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webauthn-rs-core"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"base64urlsafedata",
|
||||||
|
"der-parser",
|
||||||
|
"hex",
|
||||||
|
"nom",
|
||||||
|
"openssl",
|
||||||
|
"openssl-sys",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"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.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"base64urlsafedata",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.25.4"
|
version = "0.25.4"
|
||||||
|
|
@ -3231,15 +3565,6 @@ dependencies = [
|
||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.59.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets 0.52.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
|
|
@ -3479,6 +3804,23 @@ version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x509-parser"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
||||||
|
dependencies = [
|
||||||
|
"asn1-rs",
|
||||||
|
"data-encoding",
|
||||||
|
"der-parser",
|
||||||
|
"lazy_static",
|
||||||
|
"nom",
|
||||||
|
"oid-registry",
|
||||||
|
"rusticata-macros",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ postgres = ["sqlx/postgres"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
argon2 = "0.5"
|
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
askama = "0.12"
|
askama = "0.12"
|
||||||
|
|
@ -19,8 +18,8 @@ chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
isocountry = "0.3"
|
isocountry = "0.3"
|
||||||
|
open = "5"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] }
|
||||||
rpassword = "7.3"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|
@ -42,12 +41,17 @@ tower-cookies = "0.10"
|
||||||
slug = "0.1.6"
|
slug = "0.1.6"
|
||||||
tracing-bunyan-formatter = "0.3.10"
|
tracing-bunyan-formatter = "0.3.10"
|
||||||
tracing-log = "0.2.0"
|
tracing-log = "0.2.0"
|
||||||
|
url = "2"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] }
|
||||||
|
webauthn-rs-proto = "0.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
portpicker = "0.1"
|
portpicker = "0.1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "cookies", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "cookies", "rustls-tls"] }
|
||||||
tempfile = "3.8"
|
tempfile = "3.8"
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
|
webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] }
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
|
|
|
||||||
34
migrations/0017_passkey_auth.sql
Normal file
34
migrations/0017_passkey_auth.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- Add UUID column to users for WebAuthn user handle
|
||||||
|
ALTER TABLE users ADD COLUMN uuid TEXT;
|
||||||
|
|
||||||
|
-- Backfill existing users with random v4 UUIDs
|
||||||
|
UPDATE users SET uuid =
|
||||||
|
lower(hex(randomblob(4))) || '-' ||
|
||||||
|
lower(hex(randomblob(2))) || '-' ||
|
||||||
|
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||||
|
substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||||
|
lower(hex(randomblob(6)));
|
||||||
|
|
||||||
|
-- Passkey credential storage
|
||||||
|
CREATE TABLE passkey_credentials (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
credential_json TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT 'default',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_used_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_passkey_credentials_user_id ON passkey_credentials(user_id);
|
||||||
|
|
||||||
|
-- One-time registration tokens for bootstrap and invite flows
|
||||||
|
CREATE TABLE registration_tokens (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
used_by_user_id INTEGER REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_registration_tokens_token_hash ON registration_tokens(token_hash);
|
||||||
3
migrations/0018_remove_password_hash.sql
Normal file
3
migrations/0018_remove_password_hash.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Remove password_hash column from users (passkey-only auth)
|
||||||
|
-- SQLite 3.35+ supports ALTER TABLE DROP COLUMN
|
||||||
|
ALTER TABLE users DROP COLUMN password_hash;
|
||||||
|
|
@ -1,119 +1,54 @@
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::Form;
|
use axum::extract::{Query, State};
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Redirect, Response};
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tower_cookies::{Cookie, Cookies};
|
use tower_cookies::{Cookie, Cookies};
|
||||||
use tracing::{error, warn};
|
|
||||||
|
|
||||||
use crate::application::routes::render_html;
|
use crate::application::routes::render_html;
|
||||||
use crate::application::server::AppState;
|
use crate::application::server::AppState;
|
||||||
use crate::domain::sessions::NewSession;
|
use crate::infrastructure::auth::hash_token;
|
||||||
use crate::infrastructure::auth::{generate_session_token, hash_token, verify_password};
|
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME: &str = "brewlog_session";
|
const SESSION_COOKIE_NAME: &str = "brewlog_session";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginQuery {
|
||||||
|
pub cli_callback: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "login.html")]
|
#[template(path = "login.html")]
|
||||||
struct LoginTemplate {
|
struct LoginTemplate {
|
||||||
nav_active: &'static str,
|
nav_active: &'static str,
|
||||||
is_authenticated: bool,
|
is_authenticated: bool,
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct LoginForm {
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, cookies))]
|
#[tracing::instrument(skip(state, cookies))]
|
||||||
pub(crate) async fn login_page(
|
pub(crate) async fn login_page(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
|
Query(query): Query<LoginQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Check if already authenticated
|
// Don't redirect when CLI callback params are present — the user needs
|
||||||
if is_authenticated(&state, &cookies).await {
|
// to authenticate with their passkey to generate a bearer token for the CLI.
|
||||||
|
if query.cli_callback.is_none() && is_authenticated(&state, &cookies).await {
|
||||||
return Ok(Redirect::to("/").into_response());
|
return Ok(Redirect::to("/").into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let template = LoginTemplate {
|
let template = LoginTemplate {
|
||||||
nav_active: "login",
|
nav_active: "login",
|
||||||
is_authenticated: false,
|
is_authenticated: false,
|
||||||
|
|
||||||
error: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
render_html(template).map(IntoResponse::into_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "User attempting login", skip(state, cookies, form), fields(username = %form.username))]
|
|
||||||
pub(crate) async fn login_submit(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
cookies: Cookies,
|
|
||||||
Form(form): Form<LoginForm>,
|
|
||||||
) -> Result<Response, StatusCode> {
|
|
||||||
// Validate credentials
|
|
||||||
let user = match state.user_repo.get_by_username(&form.username).await {
|
|
||||||
Ok(user) => user,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(username = %form.username, error = %err, "login attempt with non-existent username or error");
|
|
||||||
return show_login_error("Invalid username or password");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify password
|
|
||||||
if !verify_password(&form.password, &user.password_hash)
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
||||||
{
|
|
||||||
warn!(username = %form.username, "login attempt with incorrect password");
|
|
||||||
return show_login_error("Invalid username or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create session token
|
|
||||||
let session_token = generate_session_token();
|
|
||||||
let session_token_hash = hash_token(&session_token);
|
|
||||||
|
|
||||||
// Create session in database (valid for 30 days)
|
|
||||||
let new_session = NewSession::new(
|
|
||||||
user.id,
|
|
||||||
session_token_hash,
|
|
||||||
Utc::now(),
|
|
||||||
Utc::now() + Duration::days(30),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(err) = state.session_repo.insert(new_session).await {
|
|
||||||
error!(error = %err, "failed to create session");
|
|
||||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set secure cookie
|
|
||||||
let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token);
|
|
||||||
cookie.set_path("/");
|
|
||||||
cookie.set_http_only(true);
|
|
||||||
cookie.set_same_site(tower_cookies::cookie::SameSite::Lax);
|
|
||||||
|
|
||||||
// Enable secure flag if BREWLOG_SECURE_COOKIES is set to "true"
|
|
||||||
// This should be enabled in production when serving over HTTPS
|
|
||||||
if std::env::var("BREWLOG_SECURE_COOKIES").unwrap_or_default() == "true" {
|
|
||||||
cookie.set_secure(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
cookies.add(cookie);
|
|
||||||
|
|
||||||
Ok(Redirect::to("/").into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, cookies))]
|
#[tracing::instrument(skip(state, cookies))]
|
||||||
pub(crate) async fn logout(State(state): State<AppState>, cookies: Cookies) -> Redirect {
|
pub(crate) async fn logout(State(state): State<AppState>, cookies: Cookies) -> Redirect {
|
||||||
// Try to delete session from database if cookie exists
|
|
||||||
if let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) {
|
if let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) {
|
||||||
let session_token = cookie.value();
|
let session_token = cookie.value();
|
||||||
let session_token_hash = hash_token(session_token);
|
let session_token_hash = hash_token(session_token);
|
||||||
|
|
||||||
// Try to find and delete the session
|
|
||||||
if let Ok(session) = state
|
if let Ok(session) = state
|
||||||
.session_repo
|
.session_repo
|
||||||
.get_by_token_hash(&session_token_hash)
|
.get_by_token_hash(&session_token_hash)
|
||||||
|
|
@ -127,19 +62,6 @@ pub(crate) async fn logout(State(state): State<AppState>, cookies: Cookies) -> R
|
||||||
Redirect::to("/")
|
Redirect::to("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_login_error(message: &str) -> Result<Response, StatusCode> {
|
|
||||||
let template = LoginTemplate {
|
|
||||||
nav_active: "login",
|
|
||||||
is_authenticated: false,
|
|
||||||
|
|
||||||
error: Some(message.to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
render_html(template).map(IntoResponse::into_response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if user is authenticated based on session cookie
|
|
||||||
/// Validates the session token against the database
|
|
||||||
#[tracing::instrument(skip(state, cookies))]
|
#[tracing::instrument(skip(state, cookies))]
|
||||||
pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool {
|
pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool {
|
||||||
let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) else {
|
let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) else {
|
||||||
|
|
@ -149,7 +71,6 @@ pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool {
|
||||||
let session_token = cookie.value();
|
let session_token = cookie.value();
|
||||||
let session_token_hash = hash_token(session_token);
|
let session_token_hash = hash_token(session_token);
|
||||||
|
|
||||||
// Check if session exists and is valid
|
|
||||||
match state
|
match state
|
||||||
.session_repo
|
.session_repo
|
||||||
.get_by_token_hash(&session_token_hash)
|
.get_by_token_hash(&session_token_hash)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod scan;
|
||||||
pub mod support;
|
pub mod support;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
pub mod tokens;
|
pub mod tokens;
|
||||||
|
pub mod webauthn;
|
||||||
|
|
||||||
pub(crate) use auth::is_authenticated;
|
pub(crate) use auth::is_authenticated;
|
||||||
|
|
||||||
|
|
@ -105,18 +106,28 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)),
|
post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let webauthn_routes = axum::Router::new()
|
||||||
|
.route("/register/start", post(webauthn::register_start))
|
||||||
|
.route("/register/finish", post(webauthn::register_finish))
|
||||||
|
.route("/auth/start", get(webauthn::auth_start))
|
||||||
|
.route("/auth/finish", post(webauthn::auth_finish));
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
.route("/", get(home::home_page))
|
.route("/", get(home::home_page))
|
||||||
.route("/login", get(auth::login_page).post(auth::login_submit))
|
.route("/login", get(auth::login_page))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
|
.route("/register/:token", get(webauthn::register_page))
|
||||||
|
.route("/auth/cli-callback", get(webauthn::cli_callback_page))
|
||||||
.route("/data", get(data::data_page))
|
.route("/data", get(data::data_page))
|
||||||
.route("/add", get(add::add_page))
|
.route("/add", get(add::add_page))
|
||||||
.route("/scan", get(scan_redirect))
|
.route("/scan", get(scan_redirect))
|
||||||
.route("/check-in", get(checkin::checkin_page))
|
.route("/check-in", get(checkin::checkin_page))
|
||||||
.route("/timeline", get(timeline::timeline_page))
|
.route("/timeline", get(timeline::timeline_page))
|
||||||
.route("/styles.css", get(styles))
|
.route("/styles.css", get(styles))
|
||||||
|
.route("/webauthn.js", get(webauthn_js))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
.nest("/api/v1", api_routes)
|
.nest("/api/v1", api_routes)
|
||||||
|
.nest("/api/v1/webauthn", webauthn_routes)
|
||||||
.layer(ServiceBuilder::new().layer(CookieManagerLayer::new()))
|
.layer(ServiceBuilder::new().layer(CookieManagerLayer::new()))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +143,13 @@ async fn styles() -> impl IntoResponse {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn webauthn_js() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[("content-type", "application/javascript; charset=utf-8")],
|
||||||
|
include_str!("../../../templates/webauthn.js"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn favicon() -> impl IntoResponse {
|
async fn favicon() -> impl IntoResponse {
|
||||||
(
|
(
|
||||||
[("content-type", "image/x-icon")],
|
[("content-type", "image/x-icon")],
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,10 @@ use crate::application::auth::AuthenticatedUser;
|
||||||
use crate::application::server::AppState;
|
use crate::application::server::AppState;
|
||||||
use crate::domain::ids::{TokenId, UserId};
|
use crate::domain::ids::{TokenId, UserId};
|
||||||
use crate::domain::tokens::{NewToken, Token};
|
use crate::domain::tokens::{NewToken, Token};
|
||||||
use crate::infrastructure::auth::{generate_token, hash_token, verify_password};
|
use crate::infrastructure::auth::{generate_token, hash_token};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateTokenRequest {
|
pub struct CreateTokenRequest {
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,33 +45,17 @@ impl From<Token> for TokenResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, payload), fields(token_name = %payload.name, username = %payload.username))]
|
#[tracing::instrument(skip(state, auth_user, payload), fields(token_name = %payload.name))]
|
||||||
pub async fn create_token(
|
pub async fn create_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthenticatedUser,
|
||||||
Json(payload): Json<CreateTokenRequest>,
|
Json(payload): Json<CreateTokenRequest>,
|
||||||
) -> Result<Json<CreateTokenResponse>, StatusCode> {
|
) -> Result<Json<CreateTokenResponse>, StatusCode> {
|
||||||
// Verify username and password
|
|
||||||
let user = state
|
|
||||||
.user_repo
|
|
||||||
.get_by_username(&payload.username)
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
|
||||||
|
|
||||||
let password_valid = verify_password(&payload.password, &user.password_hash)
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
if !password_valid {
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new token
|
|
||||||
let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let token_hash_value = hash_token(&token_value);
|
||||||
|
|
||||||
let token_hash = hash_token(&token_value);
|
let new_token = NewToken::new(auth_user.0.id, token_hash_value, payload.name.clone());
|
||||||
|
|
||||||
let new_token = NewToken::new(user.id, token_hash, payload.name.clone());
|
|
||||||
|
|
||||||
// Store token
|
|
||||||
let stored_token = state
|
let stored_token = state
|
||||||
.token_repo
|
.token_repo
|
||||||
.insert(new_token)
|
.insert(new_token)
|
||||||
|
|
|
||||||
445
src/application/routes/webauthn.rs
Normal file
445
src/application/routes/webauthn.rs
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
use askama::Template;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tower_cookies::{Cookie, Cookies};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use webauthn_rs::prelude::*;
|
||||||
|
|
||||||
|
use crate::application::routes::render_html;
|
||||||
|
use crate::application::server::AppState;
|
||||||
|
use crate::domain::passkey_credentials::NewPasskeyCredential;
|
||||||
|
use crate::domain::sessions::NewSession;
|
||||||
|
use crate::domain::tokens::NewToken;
|
||||||
|
use crate::domain::users::NewUser;
|
||||||
|
use crate::infrastructure::auth::{generate_session_token, generate_token, hash_token};
|
||||||
|
use crate::infrastructure::webauthn::CliCallbackInfo;
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME: &str = "brewlog_session";
|
||||||
|
|
||||||
|
// --- Templates ---
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "register.html")]
|
||||||
|
struct RegisterTemplate {
|
||||||
|
nav_active: &'static str,
|
||||||
|
is_authenticated: bool,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "cli_callback.html")]
|
||||||
|
struct CliCallbackTemplate {
|
||||||
|
nav_active: &'static str,
|
||||||
|
is_authenticated: bool,
|
||||||
|
token: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Request/Response types ---
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterStartRequest {
|
||||||
|
pub token: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ChallengeResponse<T: Serialize> {
|
||||||
|
pub challenge_id: String,
|
||||||
|
pub options: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterFinishRequest {
|
||||||
|
pub challenge_id: String,
|
||||||
|
pub credential: RegisterPublicKeyCredential,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthStartQuery {
|
||||||
|
pub cli_callback: Option<String>,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub token_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AuthStartResponse {
|
||||||
|
pub challenge_id: String,
|
||||||
|
pub options: RequestChallengeResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AuthFinishRequest {
|
||||||
|
pub challenge_id: String,
|
||||||
|
pub credential: PublicKeyCredential,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AuthFinishResponse {
|
||||||
|
pub redirect: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Registration page (bootstrap flow) ---
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state))]
|
||||||
|
pub(crate) async fn register_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
// Validate the token exists and is usable
|
||||||
|
let token_hash = hash_token(&token);
|
||||||
|
let reg_token = state
|
||||||
|
.registration_token_repo
|
||||||
|
.get_by_token_hash(&token_hash)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
warn!(
|
||||||
|
%err,
|
||||||
|
token_hash_prefix = &token_hash[..8],
|
||||||
|
"registration token lookup failed"
|
||||||
|
);
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !reg_token.is_valid() {
|
||||||
|
return Err(StatusCode::GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = RegisterTemplate {
|
||||||
|
nav_active: "",
|
||||||
|
is_authenticated: false,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Registration start (creates user + begins ceremony) ---
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, payload), fields(display_name = %payload.display_name))]
|
||||||
|
pub(crate) async fn register_start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<RegisterStartRequest>,
|
||||||
|
) -> Result<Json<ChallengeResponse<CreationChallengeResponse>>, StatusCode> {
|
||||||
|
// Validate registration token
|
||||||
|
let token_hash = hash_token(&payload.token);
|
||||||
|
let reg_token = state
|
||||||
|
.registration_token_repo
|
||||||
|
.get_by_token_hash(&token_hash)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
if !reg_token.is_valid() {
|
||||||
|
return Err(StatusCode::GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the user
|
||||||
|
let user_uuid = Uuid::new_v4().to_string();
|
||||||
|
let new_user = NewUser::new(payload.display_name, user_uuid.clone());
|
||||||
|
let user = state.user_repo.insert(new_user).await.map_err(|err| {
|
||||||
|
error!(error = %err, "failed to create user during registration");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Mark registration token as used
|
||||||
|
let _ = state
|
||||||
|
.registration_token_repo
|
||||||
|
.mark_used(reg_token.id, user.id)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Start passkey registration ceremony
|
||||||
|
let webauthn_uuid =
|
||||||
|
Uuid::parse_str(&user_uuid).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let exclude_credentials = Vec::new();
|
||||||
|
|
||||||
|
let (ccr, reg_state) = state
|
||||||
|
.webauthn
|
||||||
|
.start_passkey_registration(
|
||||||
|
webauthn_uuid,
|
||||||
|
&user.username,
|
||||||
|
&user.username,
|
||||||
|
Some(exclude_credentials),
|
||||||
|
)
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(error = %err, "failed to start passkey registration");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Store ceremony state
|
||||||
|
let challenge_id = generate_session_token();
|
||||||
|
state
|
||||||
|
.challenge_store
|
||||||
|
.store_registration(challenge_id.clone(), user.id, reg_state)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(ChallengeResponse {
|
||||||
|
challenge_id,
|
||||||
|
options: ccr,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Registration finish ---
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, cookies, payload))]
|
||||||
|
pub(crate) async fn register_finish(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
cookies: Cookies,
|
||||||
|
Json(payload): Json<RegisterFinishRequest>,
|
||||||
|
) -> Result<Json<AuthFinishResponse>, StatusCode> {
|
||||||
|
// Retrieve ceremony state
|
||||||
|
let (user_id, reg_state) = state
|
||||||
|
.challenge_store
|
||||||
|
.take_registration(&payload.challenge_id)
|
||||||
|
.await
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
// Complete registration
|
||||||
|
let passkey = state
|
||||||
|
.webauthn
|
||||||
|
.finish_passkey_registration(&payload.credential, ®_state)
|
||||||
|
.map_err(|err| {
|
||||||
|
warn!(error = %err, "passkey registration failed");
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Store the credential
|
||||||
|
let credential_json =
|
||||||
|
serde_json::to_string(&passkey).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let new_credential = NewPasskeyCredential::new(user_id, credential_json, "default".to_string());
|
||||||
|
state
|
||||||
|
.passkey_repo
|
||||||
|
.insert(new_credential)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(error = %err, "failed to store passkey credential");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!(user_id = %user_id, "passkey registered successfully");
|
||||||
|
|
||||||
|
// Create session for the new user
|
||||||
|
create_session(&state, &cookies, user_id);
|
||||||
|
|
||||||
|
Ok(Json(AuthFinishResponse { redirect: None }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Authentication start ---
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state))]
|
||||||
|
pub(crate) async fn auth_start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<AuthStartQuery>,
|
||||||
|
) -> Result<Json<AuthStartResponse>, StatusCode> {
|
||||||
|
// Load all passkey credentials from all users
|
||||||
|
let users = state
|
||||||
|
.user_repo
|
||||||
|
.list_all()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mut all_passkeys: Vec<Passkey> = Vec::new();
|
||||||
|
for user in &users {
|
||||||
|
let credentials = state
|
||||||
|
.passkey_repo
|
||||||
|
.list_by_user(user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
for cred in credentials {
|
||||||
|
let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| {
|
||||||
|
error!(error = %err, "failed to deserialize passkey credential");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
all_passkeys.push(passkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_passkeys.is_empty() {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (rcr, auth_state) = state
|
||||||
|
.webauthn
|
||||||
|
.start_passkey_authentication(&all_passkeys)
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(error = %err, "failed to start passkey authentication");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cli_callback = match (query.cli_callback, query.state, query.token_name) {
|
||||||
|
(Some(callback_url), Some(cli_state), Some(token_name)) => Some(CliCallbackInfo {
|
||||||
|
callback_url,
|
||||||
|
state: cli_state,
|
||||||
|
token_name,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let challenge_id = generate_session_token();
|
||||||
|
state
|
||||||
|
.challenge_store
|
||||||
|
.store_authentication(challenge_id.clone(), auth_state, cli_callback)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(AuthStartResponse {
|
||||||
|
challenge_id,
|
||||||
|
options: rcr,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Authentication finish ---
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, cookies, payload))]
|
||||||
|
pub(crate) async fn auth_finish(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
cookies: Cookies,
|
||||||
|
Json(payload): Json<AuthFinishRequest>,
|
||||||
|
) -> Result<Json<AuthFinishResponse>, StatusCode> {
|
||||||
|
// Retrieve ceremony state
|
||||||
|
let (auth_state, cli_callback) = state
|
||||||
|
.challenge_store
|
||||||
|
.take_authentication(&payload.challenge_id)
|
||||||
|
.await
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
// Complete authentication
|
||||||
|
let auth_result = state
|
||||||
|
.webauthn
|
||||||
|
.finish_passkey_authentication(&payload.credential, &auth_state)
|
||||||
|
.map_err(|err| {
|
||||||
|
warn!(error = %err, "passkey authentication failed");
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Find the user who owns this credential
|
||||||
|
let credential_id = auth_result.cred_id();
|
||||||
|
let users = state
|
||||||
|
.user_repo
|
||||||
|
.list_all()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mut found_user_id = None;
|
||||||
|
let mut found_cred_id = None;
|
||||||
|
let mut found_passkey: Option<Passkey> = None;
|
||||||
|
|
||||||
|
'outer: for user in &users {
|
||||||
|
let credentials = state
|
||||||
|
.passkey_repo
|
||||||
|
.list_by_user(user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
for cred in &credentials {
|
||||||
|
let passkey: Passkey = serde_json::from_str(&cred.credential_json)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
if passkey.cred_id() == credential_id {
|
||||||
|
found_user_id = Some(user.id);
|
||||||
|
found_cred_id = Some(cred.id);
|
||||||
|
found_passkey = Some(passkey);
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_id = found_user_id.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
let cred_db_id = found_cred_id.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
// Update credential counter if needed
|
||||||
|
if auth_result.needs_update()
|
||||||
|
&& let Some(mut passkey) = found_passkey
|
||||||
|
{
|
||||||
|
passkey.update_credential(&auth_result);
|
||||||
|
if let Ok(updated_json) = serde_json::to_string(&passkey) {
|
||||||
|
let _ = state
|
||||||
|
.passkey_repo
|
||||||
|
.update_credential_json(cred_db_id, &updated_json)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
let passkey_repo = state.passkey_repo.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = passkey_repo.update_last_used(cred_db_id).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle CLI callback flow
|
||||||
|
if let Some(cli_info) = cli_callback {
|
||||||
|
// Generate a bearer token for the CLI
|
||||||
|
let token_value = generate_token().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let token_hash_value = hash_token(&token_value);
|
||||||
|
let new_token = NewToken::new(user_id, token_hash_value, cli_info.token_name);
|
||||||
|
state
|
||||||
|
.token_repo
|
||||||
|
.insert(new_token)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let redirect_url = format!(
|
||||||
|
"{}?token={}&state={}",
|
||||||
|
cli_info.callback_url, token_value, cli_info.state
|
||||||
|
);
|
||||||
|
return Ok(Json(AuthFinishResponse {
|
||||||
|
redirect: Some(redirect_url),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal web login: create session
|
||||||
|
create_session(&state, &cookies, user_id);
|
||||||
|
|
||||||
|
info!(user_id = %user_id, "user authenticated via passkey");
|
||||||
|
|
||||||
|
Ok(Json(AuthFinishResponse { redirect: None }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLI callback page ---
|
||||||
|
|
||||||
|
pub(crate) async fn cli_callback_page() -> Result<Response, StatusCode> {
|
||||||
|
let template = CliCallbackTemplate {
|
||||||
|
nav_active: "",
|
||||||
|
is_authenticated: false,
|
||||||
|
token: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
fn create_session(state: &AppState, cookies: &Cookies, user_id: crate::domain::ids::UserId) {
|
||||||
|
let session_token = generate_session_token();
|
||||||
|
let session_token_hash = hash_token(&session_token);
|
||||||
|
|
||||||
|
let new_session = NewSession::new(
|
||||||
|
user_id,
|
||||||
|
session_token_hash,
|
||||||
|
Utc::now(),
|
||||||
|
Utc::now() + Duration::days(30),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the session in a fire-and-forget spawn, set the cookie optimistically.
|
||||||
|
let session_repo = state.session_repo.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = session_repo.insert(new_session).await {
|
||||||
|
error!(error = %err, "failed to create session");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token);
|
||||||
|
cookie.set_path("/");
|
||||||
|
cookie.set_http_only(true);
|
||||||
|
cookie.set_same_site(tower_cookies::cookie::SameSite::Lax);
|
||||||
|
|
||||||
|
if std::env::var("BREWLOG_SECURE_COOKIES").unwrap_or_default() == "true" {
|
||||||
|
cookie.set_secure(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.add(cookie);
|
||||||
|
}
|
||||||
|
|
@ -3,17 +3,20 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
use webauthn_rs::prelude::*;
|
||||||
|
|
||||||
use crate::application::routes::app_router;
|
use crate::application::routes::app_router;
|
||||||
|
use crate::domain::registration_tokens::NewRegistrationToken;
|
||||||
use crate::domain::repositories::{
|
use crate::domain::repositories::{
|
||||||
BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository, RoastRepository,
|
BagRepository, BrewRepository, CafeRepository, CupRepository, GearRepository,
|
||||||
RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, RoasterRepository,
|
||||||
|
SessionRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
||||||
};
|
};
|
||||||
use crate::domain::users::NewUser;
|
use crate::infrastructure::auth::{generate_session_token, hash_token};
|
||||||
use crate::infrastructure::auth::hash_password;
|
|
||||||
use crate::infrastructure::backup::BackupService;
|
use crate::infrastructure::backup::BackupService;
|
||||||
use crate::infrastructure::database::Database;
|
use crate::infrastructure::database::Database;
|
||||||
use crate::infrastructure::repositories::bags::SqlBagRepository;
|
use crate::infrastructure::repositories::bags::SqlBagRepository;
|
||||||
|
|
@ -21,18 +24,21 @@ use crate::infrastructure::repositories::brews::SqlBrewRepository;
|
||||||
use crate::infrastructure::repositories::cafes::SqlCafeRepository;
|
use crate::infrastructure::repositories::cafes::SqlCafeRepository;
|
||||||
use crate::infrastructure::repositories::cups::SqlCupRepository;
|
use crate::infrastructure::repositories::cups::SqlCupRepository;
|
||||||
use crate::infrastructure::repositories::gear::SqlGearRepository;
|
use crate::infrastructure::repositories::gear::SqlGearRepository;
|
||||||
|
use crate::infrastructure::repositories::passkey_credentials::SqlPasskeyCredentialRepository;
|
||||||
|
use crate::infrastructure::repositories::registration_tokens::SqlRegistrationTokenRepository;
|
||||||
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
|
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
|
||||||
use crate::infrastructure::repositories::roasts::SqlRoastRepository;
|
use crate::infrastructure::repositories::roasts::SqlRoastRepository;
|
||||||
use crate::infrastructure::repositories::sessions::SqlSessionRepository;
|
use crate::infrastructure::repositories::sessions::SqlSessionRepository;
|
||||||
use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
||||||
use crate::infrastructure::repositories::tokens::SqlTokenRepository;
|
use crate::infrastructure::repositories::tokens::SqlTokenRepository;
|
||||||
use crate::infrastructure::repositories::users::SqlUserRepository;
|
use crate::infrastructure::repositories::users::SqlUserRepository;
|
||||||
|
use crate::infrastructure::webauthn::ChallengeStore;
|
||||||
|
|
||||||
pub struct ServerConfig {
|
pub struct ServerConfig {
|
||||||
pub bind_address: SocketAddr,
|
pub bind_address: SocketAddr,
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub admin_password: Option<String>,
|
pub rp_id: String,
|
||||||
pub admin_username: Option<String>,
|
pub rp_origin: String,
|
||||||
pub openrouter_api_key: String,
|
pub openrouter_api_key: String,
|
||||||
pub openrouter_model: String,
|
pub openrouter_model: String,
|
||||||
pub foursquare_api_key: String,
|
pub foursquare_api_key: String,
|
||||||
|
|
@ -51,6 +57,10 @@ pub struct AppState {
|
||||||
pub user_repo: Arc<dyn UserRepository>,
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
pub token_repo: Arc<dyn TokenRepository>,
|
pub token_repo: Arc<dyn TokenRepository>,
|
||||||
pub session_repo: Arc<dyn SessionRepository>,
|
pub session_repo: Arc<dyn SessionRepository>,
|
||||||
|
pub passkey_repo: Arc<dyn PasskeyCredentialRepository>,
|
||||||
|
pub registration_token_repo: Arc<dyn RegistrationTokenRepository>,
|
||||||
|
pub webauthn: Arc<Webauthn>,
|
||||||
|
pub challenge_store: Arc<ChallengeStore>,
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
pub foursquare_url: String,
|
pub foursquare_url: String,
|
||||||
pub foursquare_api_key: String,
|
pub foursquare_api_key: String,
|
||||||
|
|
@ -59,54 +69,19 @@ pub struct AppState {
|
||||||
pub backup_service: Arc<BackupService>,
|
pub backup_service: Arc<BackupService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn new(
|
|
||||||
roaster_repo: Arc<dyn RoasterRepository>,
|
|
||||||
roast_repo: Arc<dyn RoastRepository>,
|
|
||||||
bag_repo: Arc<dyn BagRepository>,
|
|
||||||
gear_repo: Arc<dyn GearRepository>,
|
|
||||||
brew_repo: Arc<dyn BrewRepository>,
|
|
||||||
cafe_repo: Arc<dyn CafeRepository>,
|
|
||||||
cup_repo: Arc<dyn CupRepository>,
|
|
||||||
timeline_repo: Arc<dyn TimelineEventRepository>,
|
|
||||||
user_repo: Arc<dyn UserRepository>,
|
|
||||||
token_repo: Arc<dyn TokenRepository>,
|
|
||||||
session_repo: Arc<dyn SessionRepository>,
|
|
||||||
http_client: reqwest::Client,
|
|
||||||
foursquare_url: String,
|
|
||||||
foursquare_api_key: String,
|
|
||||||
openrouter_api_key: String,
|
|
||||||
openrouter_model: String,
|
|
||||||
backup_service: Arc<BackupService>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
roaster_repo,
|
|
||||||
roast_repo,
|
|
||||||
bag_repo,
|
|
||||||
gear_repo,
|
|
||||||
brew_repo,
|
|
||||||
cafe_repo,
|
|
||||||
cup_repo,
|
|
||||||
timeline_repo,
|
|
||||||
user_repo,
|
|
||||||
token_repo,
|
|
||||||
session_repo,
|
|
||||||
http_client,
|
|
||||||
foursquare_url,
|
|
||||||
foursquare_api_key,
|
|
||||||
openrouter_api_key,
|
|
||||||
openrouter_model,
|
|
||||||
backup_service,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
let database = Database::connect(&config.database_url)
|
let database = Database::connect(&config.database_url)
|
||||||
.await
|
.await
|
||||||
.context("failed to connect to database")?;
|
.context("failed to connect to database")?;
|
||||||
database.migrate().await?;
|
|
||||||
|
let rp_origin = url::Url::parse(&config.rp_origin).context("invalid BREWLOG_RP_ORIGIN URL")?;
|
||||||
|
let webauthn = Arc::new(
|
||||||
|
WebauthnBuilder::new(&config.rp_id, &rp_origin)
|
||||||
|
.context("failed to build WebAuthn instance")?
|
||||||
|
.rp_name("Brewlog")
|
||||||
|
.build()
|
||||||
|
.context("failed to build WebAuthn instance")?,
|
||||||
|
);
|
||||||
|
|
||||||
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
|
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
|
||||||
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
|
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
|
||||||
|
|
@ -122,13 +97,18 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
Arc::new(SqlTokenRepository::new(database.clone_pool()));
|
Arc::new(SqlTokenRepository::new(database.clone_pool()));
|
||||||
let session_repo: Arc<dyn SessionRepository> =
|
let session_repo: Arc<dyn SessionRepository> =
|
||||||
Arc::new(SqlSessionRepository::new(database.clone_pool()));
|
Arc::new(SqlSessionRepository::new(database.clone_pool()));
|
||||||
|
let passkey_repo: Arc<dyn PasskeyCredentialRepository> =
|
||||||
|
Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool()));
|
||||||
|
let registration_token_repo: Arc<dyn RegistrationTokenRepository> =
|
||||||
|
Arc::new(SqlRegistrationTokenRepository::new(database.clone_pool()));
|
||||||
|
|
||||||
let backup_service = Arc::new(BackupService::new(database.clone_pool()));
|
let backup_service = Arc::new(BackupService::new(database.clone_pool()));
|
||||||
|
let challenge_store = Arc::new(ChallengeStore::new());
|
||||||
|
|
||||||
// Bootstrap admin user if no users exist
|
// Bootstrap: if no users exist, generate a one-time registration token
|
||||||
bootstrap_admin_user(&user_repo, config.admin_username, config.admin_password).await?;
|
bootstrap_registration(®istration_token_repo, &user_repo, &config.rp_origin).await?;
|
||||||
|
|
||||||
let state = AppState::new(
|
let state = AppState {
|
||||||
roaster_repo,
|
roaster_repo,
|
||||||
roast_repo,
|
roast_repo,
|
||||||
bag_repo,
|
bag_repo,
|
||||||
|
|
@ -140,13 +120,17 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
user_repo,
|
user_repo,
|
||||||
token_repo,
|
token_repo,
|
||||||
session_repo,
|
session_repo,
|
||||||
reqwest::Client::new(),
|
passkey_repo,
|
||||||
crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
|
registration_token_repo,
|
||||||
config.foursquare_api_key,
|
webauthn,
|
||||||
config.openrouter_api_key,
|
challenge_store,
|
||||||
config.openrouter_model,
|
http_client: reqwest::Client::new(),
|
||||||
|
foursquare_url: crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
|
||||||
|
foursquare_api_key: config.foursquare_api_key,
|
||||||
|
openrouter_api_key: config.openrouter_api_key,
|
||||||
|
openrouter_model: config.openrouter_model,
|
||||||
backup_service,
|
backup_service,
|
||||||
);
|
};
|
||||||
|
|
||||||
let listener = TcpListener::bind(config.bind_address)
|
let listener = TcpListener::bind(config.bind_address)
|
||||||
.await
|
.await
|
||||||
|
|
@ -154,7 +138,11 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
|
|
||||||
let app: Router = app_router(state);
|
let app: Router = app_router(state);
|
||||||
|
|
||||||
info!(address = %config.bind_address, "starting HTTP server");
|
info!(
|
||||||
|
address = %config.bind_address,
|
||||||
|
database = %config.database_url,
|
||||||
|
"starting HTTP server"
|
||||||
|
);
|
||||||
|
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
|
@ -166,49 +154,39 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn bootstrap_admin_user(
|
async fn bootstrap_registration(
|
||||||
|
registration_token_repo: &Arc<dyn RegistrationTokenRepository>,
|
||||||
user_repo: &Arc<dyn UserRepository>,
|
user_repo: &Arc<dyn UserRepository>,
|
||||||
admin_username: Option<String>,
|
rp_origin: &str,
|
||||||
admin_password: Option<String>,
|
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
// Check if any users exist
|
|
||||||
let users_exist = user_repo
|
let users_exist = user_repo
|
||||||
.exists()
|
.exists()
|
||||||
.await
|
.await
|
||||||
.context("failed to check if users exist")?;
|
.context("failed to check if users exist")?;
|
||||||
|
|
||||||
if users_exist {
|
if users_exist {
|
||||||
// Users already exist, no need to bootstrap
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// No users exist - we need to create the admin user
|
// Generate one-time registration token
|
||||||
let username = admin_username.ok_or_else(|| {
|
let token = generate_session_token();
|
||||||
anyhow::anyhow!(
|
let token_hash = hash_token(&token);
|
||||||
"No users exist in the database. Please provide BREWLOG_ADMIN_USERNAME \
|
let now = Utc::now();
|
||||||
environment variable to create the admin user."
|
#[allow(clippy::expect_used)]
|
||||||
)
|
let expires_at = now
|
||||||
})?;
|
.checked_add_signed(Duration::hours(1))
|
||||||
|
.expect("timestamp overflow adding 1 hour");
|
||||||
|
|
||||||
let password = admin_password.ok_or_else(|| {
|
let new_token = NewRegistrationToken::new(token_hash, now, expires_at);
|
||||||
anyhow::anyhow!(
|
|
||||||
"No users exist in the database. Please provide BREWLOG_ADMIN_PASSWORD \
|
|
||||||
environment variable to create the admin user."
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
info!("No users found. Creating admin user '{}'...", username);
|
registration_token_repo
|
||||||
|
.insert(new_token)
|
||||||
let password_hash = hash_password(&password).context("failed to hash admin password")?;
|
|
||||||
|
|
||||||
let admin_user = NewUser::new(username, password_hash);
|
|
||||||
|
|
||||||
user_repo
|
|
||||||
.insert(admin_user)
|
|
||||||
.await
|
.await
|
||||||
.context("failed to create admin user")?;
|
.context("failed to create registration token")?;
|
||||||
|
|
||||||
info!("Admin user created successfully");
|
info!("No users found. Register the first user at:");
|
||||||
|
info!(" {}/register/{}", rp_origin, token);
|
||||||
|
info!("This link expires in 1 hour.");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,5 @@ define_id!(GearId);
|
||||||
define_id!(BrewId);
|
define_id!(BrewId);
|
||||||
define_id!(CafeId);
|
define_id!(CafeId);
|
||||||
define_id!(CupId);
|
define_id!(CupId);
|
||||||
|
define_id!(PasskeyCredentialId);
|
||||||
|
define_id!(RegistrationTokenId);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ pub mod errors;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod listing;
|
pub mod listing;
|
||||||
|
pub mod passkey_credentials;
|
||||||
|
pub mod registration_tokens;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
|
|
|
||||||
31
src/domain/passkey_credentials.rs
Normal file
31
src/domain/passkey_credentials.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::ids::{PasskeyCredentialId, UserId};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PasskeyCredential {
|
||||||
|
pub id: PasskeyCredentialId,
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub credential_json: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewPasskeyCredential {
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub credential_json: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewPasskeyCredential {
|
||||||
|
pub fn new(user_id: UserId, credential_json: String, name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id,
|
||||||
|
credential_json,
|
||||||
|
name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/domain/registration_tokens.rs
Normal file
45
src/domain/registration_tokens.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::ids::{RegistrationTokenId, UserId};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegistrationToken {
|
||||||
|
pub id: RegistrationTokenId,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub used_at: Option<DateTime<Utc>>,
|
||||||
|
pub used_by_user_id: Option<UserId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegistrationToken {
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
Utc::now() > self.expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_used(&self) -> bool {
|
||||||
|
self.used_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
!self.is_expired() && !self.is_used()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NewRegistrationToken {
|
||||||
|
pub token_hash: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewRegistrationToken {
|
||||||
|
pub fn new(token_hash: String, created_at: DateTime<Utc>, expires_at: DateTime<Utc>) -> Self {
|
||||||
|
Self {
|
||||||
|
token_hash,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,11 @@ use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
||||||
use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
|
use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
|
||||||
use crate::domain::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear};
|
use crate::domain::gear::{Gear, GearFilter, GearSortKey, NewGear, UpdateGear};
|
||||||
use crate::domain::ids::{
|
use crate::domain::ids::{
|
||||||
BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, SessionId, TokenId, UserId,
|
BagId, BrewId, CafeId, CupId, GearId, PasskeyCredentialId, RegistrationTokenId, RoastId,
|
||||||
|
RoasterId, SessionId, TokenId, UserId,
|
||||||
};
|
};
|
||||||
|
use crate::domain::passkey_credentials::{NewPasskeyCredential, PasskeyCredential};
|
||||||
|
use crate::domain::registration_tokens::{NewRegistrationToken, RegistrationToken};
|
||||||
use crate::domain::roasters::RoasterSortKey;
|
use crate::domain::roasters::RoasterSortKey;
|
||||||
use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
|
use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
|
||||||
use crate::domain::roasts::RoastSortKey;
|
use crate::domain::roasts::RoastSortKey;
|
||||||
|
|
@ -107,7 +110,9 @@ pub trait UserRepository: Send + Sync {
|
||||||
async fn insert(&self, user: NewUser) -> Result<User, RepositoryError>;
|
async fn insert(&self, user: NewUser) -> Result<User, RepositoryError>;
|
||||||
async fn get(&self, id: UserId) -> Result<User, RepositoryError>;
|
async fn get(&self, id: UserId) -> Result<User, RepositoryError>;
|
||||||
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError>;
|
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError>;
|
||||||
|
async fn get_by_uuid(&self, uuid: &str) -> Result<User, RepositoryError>;
|
||||||
async fn exists(&self) -> Result<bool, RepositoryError>;
|
async fn exists(&self) -> Result<bool, RepositoryError>;
|
||||||
|
async fn list_all(&self) -> Result<Vec<User>, RepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -219,3 +224,39 @@ pub trait CupRepository: Send + Sync {
|
||||||
async fn update(&self, id: CupId, changes: UpdateCup) -> Result<Cup, RepositoryError>;
|
async fn update(&self, id: CupId, changes: UpdateCup) -> Result<Cup, RepositoryError>;
|
||||||
async fn delete(&self, id: CupId) -> Result<(), RepositoryError>;
|
async fn delete(&self, id: CupId) -> Result<(), RepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PasskeyCredentialRepository: Send + Sync {
|
||||||
|
async fn insert(
|
||||||
|
&self,
|
||||||
|
credential: NewPasskeyCredential,
|
||||||
|
) -> Result<PasskeyCredential, RepositoryError>;
|
||||||
|
async fn list_by_user(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<Vec<PasskeyCredential>, RepositoryError>;
|
||||||
|
async fn update_credential_json(
|
||||||
|
&self,
|
||||||
|
id: PasskeyCredentialId,
|
||||||
|
credential_json: &str,
|
||||||
|
) -> Result<(), RepositoryError>;
|
||||||
|
async fn update_last_used(&self, id: PasskeyCredentialId) -> Result<(), RepositoryError>;
|
||||||
|
async fn delete(&self, id: PasskeyCredentialId) -> Result<(), RepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RegistrationTokenRepository: Send + Sync {
|
||||||
|
async fn insert(
|
||||||
|
&self,
|
||||||
|
token: NewRegistrationToken,
|
||||||
|
) -> Result<RegistrationToken, RepositoryError>;
|
||||||
|
async fn get_by_token_hash(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<RegistrationToken, RepositoryError>;
|
||||||
|
async fn mark_used(
|
||||||
|
&self,
|
||||||
|
id: RegistrationTokenId,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<(), RepositoryError>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,38 +7,29 @@ use crate::domain::ids::UserId;
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: UserId,
|
pub id: UserId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
#[serde(skip_serializing)]
|
pub uuid: String,
|
||||||
pub password_hash: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewUser {
|
pub struct NewUser {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
pub uuid: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub fn new(
|
pub fn new(id: UserId, username: String, uuid: String, created_at: DateTime<Utc>) -> Self {
|
||||||
id: UserId,
|
|
||||||
username: String,
|
|
||||||
password_hash: String,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
username,
|
username,
|
||||||
password_hash,
|
uuid,
|
||||||
created_at,
|
created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewUser {
|
impl NewUser {
|
||||||
pub fn new(username: String, password_hash: String) -> Self {
|
pub fn new(username: String, uuid: String) -> Self {
|
||||||
Self {
|
Self { username, uuid }
|
||||||
username,
|
|
||||||
password_hash,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,8 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use argon2::{
|
|
||||||
Argon2,
|
|
||||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
|
||||||
};
|
|
||||||
use base64::{Engine as _, engine::general_purpose};
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
use rand::{RngCore, rngs::OsRng};
|
use rand::{RngCore, rngs::OsRng};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
/// Hashes a password using Argon2id with secure defaults
|
|
||||||
pub fn hash_password(password: &str) -> Result<String> {
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let argon2 = Argon2::default();
|
|
||||||
|
|
||||||
let password_hash = argon2
|
|
||||||
.hash_password(password.as_bytes(), &salt)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(password_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifies a password against a hash
|
|
||||||
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
|
|
||||||
let parsed_hash = PasswordHash::new(password_hash)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
|
|
||||||
|
|
||||||
let argon2 = Argon2::default();
|
|
||||||
|
|
||||||
match argon2.verify_password(password.as_bytes(), &parsed_hash) {
|
|
||||||
Ok(()) => Ok(true),
|
|
||||||
Err(_) => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates a cryptographically secure random token
|
/// Generates a cryptographically secure random token
|
||||||
/// Returns a base64-encoded token string
|
/// Returns a base64-encoded token string
|
||||||
pub fn generate_token() -> Result<String> {
|
pub fn generate_token() -> Result<String> {
|
||||||
|
|
@ -57,44 +27,17 @@ pub fn generate_session_token() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::unwrap_used)] // Tests: unwrap is acceptable for test assertions
|
#[allow(clippy::unwrap_used)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_password_hashing() {
|
|
||||||
let password = "test_password_123";
|
|
||||||
let hash = hash_password(password).unwrap();
|
|
||||||
|
|
||||||
assert!(verify_password(password, &hash).unwrap());
|
|
||||||
assert!(!verify_password("wrong_password", &hash).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_password_hashing_different_salts() {
|
|
||||||
let password = "test_password_123";
|
|
||||||
let hash1 = hash_password(password).unwrap();
|
|
||||||
let hash2 = hash_password(password).unwrap();
|
|
||||||
|
|
||||||
// Different salts should produce different hashes
|
|
||||||
assert_ne!(hash1, hash2);
|
|
||||||
|
|
||||||
// But both should verify the same password
|
|
||||||
assert!(verify_password(password, &hash1).unwrap());
|
|
||||||
assert!(verify_password(password, &hash2).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_token_generation() {
|
fn test_token_generation() {
|
||||||
let token1 = generate_token().unwrap();
|
let token1 = generate_token().unwrap();
|
||||||
let token2 = generate_token().unwrap();
|
let token2 = generate_token().unwrap();
|
||||||
|
|
||||||
// Tokens should be different
|
|
||||||
assert_ne!(token1, token2);
|
assert_ne!(token1, token2);
|
||||||
|
|
||||||
// Tokens should be base64 encoded (at least 40 chars for 32 bytes)
|
|
||||||
assert!(token1.len() >= 40);
|
assert!(token1.len() >= 40);
|
||||||
assert!(token2.len() >= 40);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -103,12 +46,9 @@ mod tests {
|
||||||
let hash1 = hash_token(token);
|
let hash1 = hash_token(token);
|
||||||
let hash2 = hash_token(token);
|
let hash2 = hash_token(token);
|
||||||
|
|
||||||
// Same token should produce same hash
|
|
||||||
assert_eq!(hash1, hash2);
|
assert_eq!(hash1, hash2);
|
||||||
|
|
||||||
// Different token should produce different hash
|
let hash3 = hash_token("different_token");
|
||||||
let different_token = "different_token";
|
|
||||||
let hash3 = hash_token(different_token);
|
|
||||||
assert_ne!(hash1, hash3);
|
assert_ne!(hash1, hash3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,6 @@ impl BrewlogClient {
|
||||||
.with_context(|| format!("invalid API path: {path}"))
|
.with_context(|| format!("invalid API path: {path}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn http_client(&self) -> &Client {
|
|
||||||
&self.http
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a request with authentication if token is available
|
/// Build a request with authentication if token is available
|
||||||
pub(crate) fn request(&self, method: reqwest::Method, url: Url) -> reqwest::RequestBuilder {
|
pub(crate) fn request(&self, method: reqwest::Method, url: Url) -> reqwest::RequestBuilder {
|
||||||
let mut request = self.http.request(method, url);
|
let mut request = self.http.request(method, url);
|
||||||
|
|
|
||||||
|
|
@ -14,30 +14,6 @@ impl<'a> TokensClient<'a> {
|
||||||
Self { client }
|
Self { client }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(
|
|
||||||
&self,
|
|
||||||
username: &str,
|
|
||||||
password: &str,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<TokenResponse> {
|
|
||||||
let url = self.client.endpoint("api/v1/tokens")?;
|
|
||||||
let body = CreateTokenRequest {
|
|
||||||
username: username.to_string(),
|
|
||||||
password: password.to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.http_client()
|
|
||||||
.post(url)
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self.client.handle_response(response).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list(&self) -> Result<Vec<TokenInfo>> {
|
pub async fn list(&self) -> Result<Vec<TokenInfo>> {
|
||||||
let url = self.client.endpoint("api/v1/tokens")?;
|
let url = self.client.endpoint("api/v1/tokens")?;
|
||||||
|
|
||||||
|
|
@ -65,13 +41,6 @@ impl<'a> TokensClient<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct CreateTokenRequest {
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TokenResponse {
|
pub struct TokenResponse {
|
||||||
pub id: TokenId,
|
pub id: TokenId,
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,4 @@ pub mod client;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod foursquare;
|
pub mod foursquare;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
|
pub mod webauthn;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ pub mod cups;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
|
pub mod passkey_credentials;
|
||||||
|
pub mod registration_tokens;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
|
|
||||||
145
src/infrastructure/repositories/passkey_credentials.rs
Normal file
145
src/infrastructure/repositories/passkey_credentials.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{query, query_as};
|
||||||
|
|
||||||
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::ids::{PasskeyCredentialId, UserId};
|
||||||
|
use crate::domain::passkey_credentials::{NewPasskeyCredential, PasskeyCredential};
|
||||||
|
use crate::domain::repositories::PasskeyCredentialRepository;
|
||||||
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqlPasskeyCredentialRepository {
|
||||||
|
pool: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlPasskeyCredentialRepository {
|
||||||
|
pub fn new(pool: DatabasePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_domain(record: PasskeyCredentialRecord) -> PasskeyCredential {
|
||||||
|
let PasskeyCredentialRecord {
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
credential_json,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
last_used_at,
|
||||||
|
} = record;
|
||||||
|
|
||||||
|
PasskeyCredential {
|
||||||
|
id: PasskeyCredentialId::from(id),
|
||||||
|
user_id: UserId::from(user_id),
|
||||||
|
credential_json,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
last_used_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository {
|
||||||
|
async fn insert(
|
||||||
|
&self,
|
||||||
|
credential: NewPasskeyCredential,
|
||||||
|
) -> Result<PasskeyCredential, RepositoryError> {
|
||||||
|
let sql = r"
|
||||||
|
INSERT INTO passkey_credentials (user_id, credential_json, name)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
RETURNING id, user_id, credential_json, name, created_at, last_used_at
|
||||||
|
";
|
||||||
|
|
||||||
|
let record = query_as::<_, PasskeyCredentialRecord>(sql)
|
||||||
|
.bind(i64::from(credential.user_id))
|
||||||
|
.bind(&credential.credential_json)
|
||||||
|
.bind(&credential.name)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to insert passkey credential: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self::to_domain(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_by_user(
|
||||||
|
&self,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<Vec<PasskeyCredential>, RepositoryError> {
|
||||||
|
let sql = r"
|
||||||
|
SELECT id, user_id, credential_json, name, created_at, last_used_at
|
||||||
|
FROM passkey_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
";
|
||||||
|
|
||||||
|
let records = query_as::<_, PasskeyCredentialRecord>(sql)
|
||||||
|
.bind(i64::from(user_id))
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to list passkey credentials: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(records.into_iter().map(Self::to_domain).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_credential_json(
|
||||||
|
&self,
|
||||||
|
id: PasskeyCredentialId,
|
||||||
|
credential_json: &str,
|
||||||
|
) -> Result<(), RepositoryError> {
|
||||||
|
query("UPDATE passkey_credentials SET credential_json = ? WHERE id = ?")
|
||||||
|
.bind(credential_json)
|
||||||
|
.bind(i64::from(id))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!(
|
||||||
|
"failed to update passkey credential json: {err}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_last_used(&self, id: PasskeyCredentialId) -> Result<(), RepositoryError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
query("UPDATE passkey_credentials SET last_used_at = ? WHERE id = ?")
|
||||||
|
.bind(now)
|
||||||
|
.bind(i64::from(id))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!(
|
||||||
|
"failed to update passkey credential last_used_at: {err}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: PasskeyCredentialId) -> Result<(), RepositoryError> {
|
||||||
|
query("DELETE FROM passkey_credentials WHERE id = ?")
|
||||||
|
.bind(i64::from(id))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to delete passkey credential: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct PasskeyCredentialRecord {
|
||||||
|
id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
credential_json: String,
|
||||||
|
name: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
last_used_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
121
src/infrastructure/repositories/registration_tokens.rs
Normal file
121
src/infrastructure/repositories/registration_tokens.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::query_as;
|
||||||
|
|
||||||
|
use crate::domain::RepositoryError;
|
||||||
|
use crate::domain::ids::{RegistrationTokenId, UserId};
|
||||||
|
use crate::domain::registration_tokens::{NewRegistrationToken, RegistrationToken};
|
||||||
|
use crate::domain::repositories::RegistrationTokenRepository;
|
||||||
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqlRegistrationTokenRepository {
|
||||||
|
pool: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlRegistrationTokenRepository {
|
||||||
|
pub fn new(pool: DatabasePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_domain(record: RegistrationTokenRecord) -> RegistrationToken {
|
||||||
|
let RegistrationTokenRecord {
|
||||||
|
id,
|
||||||
|
token_hash,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
used_by_user_id,
|
||||||
|
} = record;
|
||||||
|
|
||||||
|
RegistrationToken {
|
||||||
|
id: RegistrationTokenId::from(id),
|
||||||
|
token_hash,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
used_by_user_id: used_by_user_id.map(UserId::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RegistrationTokenRepository for SqlRegistrationTokenRepository {
|
||||||
|
async fn insert(
|
||||||
|
&self,
|
||||||
|
token: NewRegistrationToken,
|
||||||
|
) -> Result<RegistrationToken, RepositoryError> {
|
||||||
|
let sql = r"
|
||||||
|
INSERT INTO registration_tokens (token_hash, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
RETURNING id, token_hash, created_at, expires_at, used_at, used_by_user_id
|
||||||
|
";
|
||||||
|
|
||||||
|
let record = query_as::<_, RegistrationTokenRecord>(sql)
|
||||||
|
.bind(&token.token_hash)
|
||||||
|
.bind(token.created_at)
|
||||||
|
.bind(token.expires_at)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to insert registration token: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self::to_domain(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_token_hash(
|
||||||
|
&self,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<RegistrationToken, RepositoryError> {
|
||||||
|
let sql = r"
|
||||||
|
SELECT id, token_hash, created_at, expires_at, used_at, used_by_user_id
|
||||||
|
FROM registration_tokens
|
||||||
|
WHERE token_hash = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
let record = query_as::<_, RegistrationTokenRecord>(sql)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!("failed to get registration token: {err}"))
|
||||||
|
})?
|
||||||
|
.ok_or(RepositoryError::NotFound)?;
|
||||||
|
|
||||||
|
Ok(Self::to_domain(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_used(
|
||||||
|
&self,
|
||||||
|
id: RegistrationTokenId,
|
||||||
|
user_id: UserId,
|
||||||
|
) -> Result<(), RepositoryError> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let sql = "UPDATE registration_tokens SET used_at = ?, used_by_user_id = ? WHERE id = ?";
|
||||||
|
|
||||||
|
sqlx::query(sql)
|
||||||
|
.bind(now)
|
||||||
|
.bind(i64::from(user_id))
|
||||||
|
.bind(i64::from(id))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
RepositoryError::unexpected(format!(
|
||||||
|
"failed to mark registration token as used: {err}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct RegistrationTokenRecord {
|
||||||
|
id: i64,
|
||||||
|
token_hash: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
used_at: Option<DateTime<Utc>>,
|
||||||
|
used_by_user_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
@ -22,22 +22,22 @@ impl SqlUserRepository {
|
||||||
let UserRecord {
|
let UserRecord {
|
||||||
id,
|
id,
|
||||||
username,
|
username,
|
||||||
password_hash,
|
uuid,
|
||||||
created_at,
|
created_at,
|
||||||
} = record;
|
} = record;
|
||||||
|
|
||||||
User::new(UserId::from(id), username, password_hash, created_at)
|
User::new(UserId::from(id), username, uuid, created_at)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserRepository for SqlUserRepository {
|
impl UserRepository for SqlUserRepository {
|
||||||
async fn insert(&self, user: NewUser) -> Result<User, RepositoryError> {
|
async fn insert(&self, user: NewUser) -> Result<User, RepositoryError> {
|
||||||
let query = "INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING id, username, password_hash, created_at";
|
let query = "INSERT INTO users (username, uuid) VALUES (?, ?) RETURNING id, username, uuid, created_at";
|
||||||
|
|
||||||
let record = sqlx::query_as::<_, UserRecord>(query)
|
let record = sqlx::query_as::<_, UserRecord>(query)
|
||||||
.bind(&user.username)
|
.bind(&user.username)
|
||||||
.bind(&user.password_hash)
|
.bind(&user.uuid)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
|
|
@ -53,7 +53,7 @@ impl UserRepository for SqlUserRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, id: UserId) -> Result<User, RepositoryError> {
|
async fn get(&self, id: UserId) -> Result<User, RepositoryError> {
|
||||||
let query = "SELECT id, username, password_hash, created_at FROM users WHERE id = ?";
|
let query = "SELECT id, username, uuid, created_at FROM users WHERE id = ?";
|
||||||
|
|
||||||
let record = query_as::<_, UserRecord>(query)
|
let record = query_as::<_, UserRecord>(query)
|
||||||
.bind(i64::from(id))
|
.bind(i64::from(id))
|
||||||
|
|
@ -66,7 +66,7 @@ impl UserRepository for SqlUserRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError> {
|
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError> {
|
||||||
let query = "SELECT id, username, password_hash, created_at FROM users WHERE username = ?";
|
let query = "SELECT id, username, uuid, created_at FROM users WHERE username = ?";
|
||||||
|
|
||||||
let record = query_as::<_, UserRecord>(query)
|
let record = query_as::<_, UserRecord>(query)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
|
|
@ -78,6 +78,19 @@ impl UserRepository for SqlUserRepository {
|
||||||
Ok(Self::to_domain(record))
|
Ok(Self::to_domain(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_by_uuid(&self, uuid: &str) -> Result<User, RepositoryError> {
|
||||||
|
let query = "SELECT id, username, uuid, created_at FROM users WHERE uuid = ?";
|
||||||
|
|
||||||
|
let record = query_as::<_, UserRecord>(query)
|
||||||
|
.bind(uuid)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
|
||||||
|
.ok_or(RepositoryError::NotFound)?;
|
||||||
|
|
||||||
|
Ok(Self::to_domain(record))
|
||||||
|
}
|
||||||
|
|
||||||
async fn exists(&self) -> Result<bool, RepositoryError> {
|
async fn exists(&self) -> Result<bool, RepositoryError> {
|
||||||
let query = "SELECT COUNT(*) FROM users";
|
let query = "SELECT COUNT(*) FROM users";
|
||||||
|
|
||||||
|
|
@ -88,12 +101,23 @@ impl UserRepository for SqlUserRepository {
|
||||||
|
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_all(&self) -> Result<Vec<User>, RepositoryError> {
|
||||||
|
let query = "SELECT id, username, uuid, created_at FROM users ORDER BY created_at ASC";
|
||||||
|
|
||||||
|
let records = query_as::<_, UserRecord>(query)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
Ok(records.into_iter().map(Self::to_domain).collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct UserRecord {
|
struct UserRecord {
|
||||||
id: i64,
|
id: i64,
|
||||||
username: String,
|
username: String,
|
||||||
password_hash: String,
|
uuid: String,
|
||||||
created_at: DateTime<Utc>,
|
created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
src/infrastructure/webauthn.rs
Normal file
118
src/infrastructure/webauthn.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration};
|
||||||
|
|
||||||
|
use crate::domain::ids::UserId;
|
||||||
|
|
||||||
|
/// Stores in-flight `WebAuthn` ceremony state between start/finish calls.
|
||||||
|
/// Entries expire after 5 minutes.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ChallengeStore {
|
||||||
|
registrations: Arc<RwLock<HashMap<String, RegistrationEntry>>>,
|
||||||
|
authentications: Arc<RwLock<HashMap<String, AuthenticationEntry>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RegistrationEntry {
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub state: PasskeyRegistration,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AuthenticationEntry {
|
||||||
|
pub state: PasskeyAuthentication,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub cli_callback: Option<CliCallbackInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CliCallbackInfo {
|
||||||
|
pub callback_url: String,
|
||||||
|
pub state: String,
|
||||||
|
pub token_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHALLENGE_TTL_MINUTES: i64 = 5;
|
||||||
|
|
||||||
|
impl Default for ChallengeStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChallengeStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
registrations: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
authentications: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn store_registration(
|
||||||
|
&self,
|
||||||
|
challenge_id: String,
|
||||||
|
user_id: UserId,
|
||||||
|
state: PasskeyRegistration,
|
||||||
|
) {
|
||||||
|
let entry = RegistrationEntry {
|
||||||
|
user_id,
|
||||||
|
state,
|
||||||
|
expires_at: Utc::now() + Duration::minutes(CHALLENGE_TTL_MINUTES),
|
||||||
|
};
|
||||||
|
let mut map = self.registrations.write().await;
|
||||||
|
Self::cleanup_expired_registrations(&mut map);
|
||||||
|
map.insert(challenge_id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn take_registration(
|
||||||
|
&self,
|
||||||
|
challenge_id: &str,
|
||||||
|
) -> Option<(UserId, PasskeyRegistration)> {
|
||||||
|
let mut map = self.registrations.write().await;
|
||||||
|
let entry = map.remove(challenge_id)?;
|
||||||
|
if Utc::now() > entry.expires_at {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((entry.user_id, entry.state))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn store_authentication(
|
||||||
|
&self,
|
||||||
|
challenge_id: String,
|
||||||
|
state: PasskeyAuthentication,
|
||||||
|
cli_callback: Option<CliCallbackInfo>,
|
||||||
|
) {
|
||||||
|
let entry = AuthenticationEntry {
|
||||||
|
state,
|
||||||
|
expires_at: Utc::now() + Duration::minutes(CHALLENGE_TTL_MINUTES),
|
||||||
|
cli_callback,
|
||||||
|
};
|
||||||
|
let mut map = self.authentications.write().await;
|
||||||
|
Self::cleanup_expired_authentications(&mut map);
|
||||||
|
map.insert(challenge_id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn take_authentication(
|
||||||
|
&self,
|
||||||
|
challenge_id: &str,
|
||||||
|
) -> Option<(PasskeyAuthentication, Option<CliCallbackInfo>)> {
|
||||||
|
let mut map = self.authentications.write().await;
|
||||||
|
let entry = map.remove(challenge_id)?;
|
||||||
|
if Utc::now() > entry.expires_at {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((entry.state, entry.cli_callback))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_expired_registrations(map: &mut HashMap<String, RegistrationEntry>) {
|
||||||
|
let now = Utc::now();
|
||||||
|
map.retain(|_, entry| entry.expires_at > now);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_expired_authentications(map: &mut HashMap<String, AuthenticationEntry>) {
|
||||||
|
let now = Utc::now();
|
||||||
|
map.retain(|_, entry| entry.expires_at > now);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/main.rs
19
src/main.rs
|
|
@ -6,7 +6,6 @@ use brewlog::presentation::cli::{
|
||||||
Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, tokens,
|
Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, tokens,
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use tracing::{Subscriber, subscriber::set_global_default};
|
use tracing::{Subscriber, subscriber::set_global_default};
|
||||||
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
|
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
|
||||||
use tracing_log::LogTracer;
|
use tracing_log::LogTracer;
|
||||||
|
|
@ -76,6 +75,20 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_server(command: ServeCommand) -> Result<()> {
|
async fn run_server(command: ServeCommand) -> Result<()> {
|
||||||
|
let rp_id = command.rp_id.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"BREWLOG_RP_ID is required. Set this to the domain where the app is hosted \
|
||||||
|
(e.g. 'brewlog.example.com' or 'localhost')."
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let rp_origin = command.rp_origin.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"BREWLOG_RP_ORIGIN is required. Set this to the full origin URL \
|
||||||
|
(e.g. 'https://brewlog.example.com' or 'http://localhost:3000')."
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let openrouter_api_key = command.openrouter_api_key.ok_or_else(|| {
|
let openrouter_api_key = command.openrouter_api_key.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"BREWLOG_OPENROUTER_API_KEY is required. Set this environment variable \
|
"BREWLOG_OPENROUTER_API_KEY is required. Set this environment variable \
|
||||||
|
|
@ -93,8 +106,8 @@ async fn run_server(command: ServeCommand) -> Result<()> {
|
||||||
let config = ServerConfig {
|
let config = ServerConfig {
|
||||||
bind_address: command.bind_address,
|
bind_address: command.bind_address,
|
||||||
database_url: command.database_url,
|
database_url: command.database_url,
|
||||||
admin_password: command.admin_password,
|
rp_id,
|
||||||
admin_username: command.admin_username,
|
rp_origin,
|
||||||
openrouter_api_key,
|
openrouter_api_key,
|
||||||
openrouter_model: command.openrouter_model,
|
openrouter_model: command.openrouter_model,
|
||||||
foursquare_api_key,
|
foursquare_api_key,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ pub struct Cli {
|
||||||
long,
|
long,
|
||||||
global = true,
|
global = true,
|
||||||
env = "BREWLOG_URL",
|
env = "BREWLOG_URL",
|
||||||
default_value = "http://127.0.0.1:3000"
|
default_value = "http://localhost:3000"
|
||||||
)]
|
)]
|
||||||
pub api_url: String,
|
pub api_url: String,
|
||||||
|
|
||||||
|
|
@ -109,11 +109,11 @@ pub struct ServeCommand {
|
||||||
#[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")]
|
#[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")]
|
||||||
pub bind_address: SocketAddr,
|
pub bind_address: SocketAddr,
|
||||||
|
|
||||||
#[arg(long, env = "BREWLOG_ADMIN_PASSWORD")]
|
#[arg(long, env = "BREWLOG_RP_ID")]
|
||||||
pub admin_password: Option<String>,
|
pub rp_id: Option<String>,
|
||||||
|
|
||||||
#[arg(long, env = "BREWLOG_ADMIN_USERNAME")]
|
#[arg(long, env = "BREWLOG_RP_ORIGIN")]
|
||||||
pub admin_username: Option<String>,
|
pub rp_origin: Option<String>,
|
||||||
|
|
||||||
#[arg(long, env = "BREWLOG_OPENROUTER_API_KEY")]
|
#[arg(long, env = "BREWLOG_OPENROUTER_API_KEY")]
|
||||||
pub openrouter_api_key: Option<String>,
|
pub openrouter_api_key: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
use anyhow::{Context, Result};
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
use std::io::{self, Write};
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
use super::print_json;
|
use super::print_json;
|
||||||
use crate::domain::ids::TokenId;
|
use crate::domain::ids::TokenId;
|
||||||
|
use crate::infrastructure::auth::generate_session_token;
|
||||||
use crate::infrastructure::client::BrewlogClient;
|
use crate::infrastructure::client::BrewlogClient;
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum TokenCommands {
|
pub enum TokenCommands {
|
||||||
/// Create a new API token
|
/// Create a new API token (opens browser for passkey authentication)
|
||||||
Create(CreateTokenCommand),
|
Create(CreateTokenCommand),
|
||||||
/// List all tokens
|
/// List all tokens
|
||||||
List,
|
List,
|
||||||
|
|
@ -29,14 +33,6 @@ pub struct CreateTokenCommand {
|
||||||
/// A descriptive name for this token
|
/// A descriptive name for this token
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
/// The username to authenticate with
|
|
||||||
#[arg(long)]
|
|
||||||
pub username: Option<String>,
|
|
||||||
|
|
||||||
/// The password to authenticate with
|
|
||||||
#[arg(long)]
|
|
||||||
pub password: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
|
|
@ -47,41 +43,130 @@ pub struct RevokeTokenCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Result<()> {
|
pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Result<()> {
|
||||||
let username = if let Some(u) = cmd.username {
|
let state = generate_session_token();
|
||||||
u
|
|
||||||
} else {
|
// Start a local server on a random port to receive the callback
|
||||||
// Prompt for username
|
let listener = TcpListener::bind("127.0.0.1:0")
|
||||||
print!("Username: ");
|
.await
|
||||||
io::stdout().flush()?;
|
.context("failed to bind local callback server")?;
|
||||||
let mut username = String::new();
|
let local_addr = listener
|
||||||
io::stdin().read_line(&mut username)?;
|
.local_addr()
|
||||||
username.trim().to_string()
|
.context("failed to get local callback address")?;
|
||||||
|
|
||||||
|
let callback_url = format!("http://127.0.0.1:{}/callback", local_addr.port());
|
||||||
|
|
||||||
|
// Build the browser URL
|
||||||
|
let mut server_url = client
|
||||||
|
.endpoint("login")
|
||||||
|
.context("failed to build login URL")?;
|
||||||
|
server_url
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("cli_callback", &callback_url)
|
||||||
|
.append_pair("state", &state)
|
||||||
|
.append_pair("token_name", &cmd.name);
|
||||||
|
|
||||||
|
println!("Opening browser for authentication...");
|
||||||
|
println!("If the browser doesn't open, visit this URL:");
|
||||||
|
println!("\n {server_url}\n");
|
||||||
|
|
||||||
|
// Open the browser
|
||||||
|
if let Err(err) = open::that(server_url.as_str()) {
|
||||||
|
eprintln!("Warning: failed to open browser: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the callback
|
||||||
|
let (tx, rx) = oneshot::channel::<String>();
|
||||||
|
let expected_state = state.clone();
|
||||||
|
|
||||||
|
let server = tokio::spawn(run_callback_server(
|
||||||
|
listener,
|
||||||
|
local_addr,
|
||||||
|
expected_state,
|
||||||
|
tx,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Wait for the token with a timeout
|
||||||
|
let token = tokio::select! {
|
||||||
|
result = rx => {
|
||||||
|
result.context("callback server closed without receiving a token")?
|
||||||
|
}
|
||||||
|
() = tokio::time::sleep(std::time::Duration::from_secs(120)) => {
|
||||||
|
return Err(anyhow!("timed out waiting for browser authentication (2 minutes)"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let password = if let Some(p) = cmd.password {
|
// Clean up the server task
|
||||||
p
|
server.abort();
|
||||||
} else {
|
|
||||||
// Prompt for password (without echo)
|
|
||||||
rpassword::prompt_password("Password: ").context("failed to read password")?
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the token
|
println!("Token created successfully!");
|
||||||
let token_response = client
|
println!("Token Name: {}", cmd.name);
|
||||||
.tokens()
|
println!("\nSave this token securely - it will not be shown again:");
|
||||||
.create(&username, &password, &cmd.name)
|
println!("\n{token}");
|
||||||
.await?;
|
|
||||||
|
|
||||||
println!("\nToken created successfully!");
|
|
||||||
println!("Token ID: {}", token_response.id);
|
|
||||||
println!("Token Name: {}", token_response.name);
|
|
||||||
println!("\n⚠️ Save this token securely - it will not be shown again:");
|
|
||||||
println!("\n{}", token_response.token);
|
|
||||||
println!("\nExport it in your environment:");
|
println!("\nExport it in your environment:");
|
||||||
println!(" export BREWLOG_TOKEN={}", token_response.token);
|
println!(" export BREWLOG_TOKEN={token}");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_callback_server(
|
||||||
|
listener: TcpListener,
|
||||||
|
_addr: SocketAddr,
|
||||||
|
expected_state: String,
|
||||||
|
tx: oneshot::Sender<String>,
|
||||||
|
) {
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::response::Html;
|
||||||
|
use axum::routing::get;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CallbackQuery {
|
||||||
|
token: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx)));
|
||||||
|
let state_clone = expected_state.clone();
|
||||||
|
|
||||||
|
let app = axum::Router::new().route(
|
||||||
|
"/callback",
|
||||||
|
get(move |Query(query): Query<CallbackQuery>| {
|
||||||
|
let tx = tx.clone();
|
||||||
|
let expected = state_clone.clone();
|
||||||
|
async move {
|
||||||
|
let Some(token) = query.token else {
|
||||||
|
return Html(
|
||||||
|
"<html><body><h1>Error</h1><p>No token received.</p></body></html>"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(received_state) = query.state else {
|
||||||
|
return Html(
|
||||||
|
"<html><body><h1>Error</h1><p>No state parameter.</p></body></html>"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if received_state != expected {
|
||||||
|
return Html(
|
||||||
|
"<html><body><h1>Error</h1><p>State mismatch - possible CSRF attack.</p></body></html>"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sender) = tx.lock().await.take() {
|
||||||
|
let _ = sender.send(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Html("<html><body><h1>Authenticated</h1><p>You can close this window and return to the terminal.</p></body></html>".to_string())
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = axum::serve(listener, app).await;
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_tokens(client: &BrewlogClient) -> Result<()> {
|
pub async fn list_tokens(client: &BrewlogClient) -> Result<()> {
|
||||||
let tokens = client.tokens().list().await?;
|
let tokens = client.tokens().list().await?;
|
||||||
print_json(&tokens)
|
print_json(&tokens)
|
||||||
|
|
|
||||||
24
templates/cli_callback.html
Normal file
24
templates/cli_callback.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Brewlog · CLI Authentication{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
|
||||||
|
{% if token.is_some() %}
|
||||||
|
<h1 class="text-2xl font-semibold text-green-700">Authenticated</h1>
|
||||||
|
<p class="mt-2 text-sm text-stone-600">
|
||||||
|
Your CLI has been authenticated. You can close this window.
|
||||||
|
</p>
|
||||||
|
{% else if error.is_some() %}
|
||||||
|
<h1 class="text-2xl font-semibold text-red-700">Authentication Failed</h1>
|
||||||
|
<div class="mt-4 rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800">
|
||||||
|
{{ error.as_ref().unwrap() }}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="text-2xl font-semibold text-amber-700">CLI Authentication</h1>
|
||||||
|
<p class="mt-2 text-sm text-stone-600">
|
||||||
|
Processing authentication...
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,49 +1,79 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Brewlog · Login{% endblock %}
|
{% block title %}Brewlog · Login{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<script src="/webauthn.js"></script>
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-md">
|
||||||
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
|
||||||
<h1 class="text-2xl font-semibold text-amber-700">Login</h1>
|
<h1 class="text-2xl font-semibold text-amber-700">Login</h1>
|
||||||
<p class="mt-2 text-sm text-stone-600">
|
<p class="mt-2 text-sm text-stone-600">
|
||||||
Sign in to manage your roasters and roasts.
|
Sign in with your passkey to manage your coffee log.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if error.is_some() %}
|
<div id="login-error" class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div>
|
||||||
<div class="mt-4 rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800">
|
|
||||||
{{ error.as_ref().unwrap() }}
|
<div id="login-unsupported" class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800">
|
||||||
|
Your browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/login" class="mt-6 flex flex-col gap-4">
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Username</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
class="input-field"
|
|
||||||
placeholder="admin"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-sm">
|
|
||||||
<span class="text-stone-700">Password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
class="input-field"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
id="login-button"
|
||||||
class="mt-2 rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
type="button"
|
||||||
|
class="w-full rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign in with Passkey
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
|
<div id="login-loading" class="mt-4 hidden text-center text-sm text-stone-500">
|
||||||
|
<p>Waiting for passkey...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const button = document.getElementById("login-button");
|
||||||
|
const errorDiv = document.getElementById("login-error");
|
||||||
|
const loadingDiv = document.getElementById("login-loading");
|
||||||
|
const unsupportedDiv = document.getElementById("login-unsupported");
|
||||||
|
|
||||||
|
// Check WebAuthn support
|
||||||
|
if (!window.PublicKeyCredential) {
|
||||||
|
button.disabled = true;
|
||||||
|
unsupportedDiv.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for CLI callback query params
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
let queryString = "";
|
||||||
|
if (params.has("cli_callback")) {
|
||||||
|
queryString = "?" + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener("click", async function () {
|
||||||
|
errorDiv.classList.add("hidden");
|
||||||
|
loadingDiv.classList.remove("hidden");
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await startPasskeyAuthentication(queryString);
|
||||||
|
|
||||||
|
if (result.redirect) {
|
||||||
|
window.location.href = result.redirect;
|
||||||
|
} else {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.textContent = err.message;
|
||||||
|
errorDiv.classList.remove("hidden");
|
||||||
|
loadingDiv.classList.add("hidden");
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
104
templates/register.html
Normal file
104
templates/register.html
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Brewlog · Register{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<script src="/webauthn.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
|
||||||
|
<h1 class="text-2xl font-semibold text-amber-700">Register</h1>
|
||||||
|
<p class="mt-2 text-sm text-stone-600">
|
||||||
|
Create your account by registering a passkey. This will be used to sign in going forward.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="register-error" class="mt-4 hidden rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800"></div>
|
||||||
|
|
||||||
|
<div id="register-unsupported" class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800">
|
||||||
|
Your browser does not support passkeys. Please use a modern browser (Chrome, Firefox, Safari, or Edge).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="register-form" class="mt-6 flex flex-col gap-4">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Display Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="display-name"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
class="input-field"
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="register-button"
|
||||||
|
type="button"
|
||||||
|
class="mt-2 w-full rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Register Passkey
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="register-loading" class="mt-2 hidden text-center text-sm text-stone-500">
|
||||||
|
<p>Follow the prompts from your browser or device...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="register-success" class="mt-6 hidden">
|
||||||
|
<div class="rounded-md bg-green-100 border border-green-300 p-4 text-sm text-green-800">
|
||||||
|
<p class="font-semibold">Registration successful!</p>
|
||||||
|
<p class="mt-1">Your passkey has been registered and you are now signed in.</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-4 block w-full rounded-md bg-amber-600 px-4 py-3 text-center text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
Go to Brewlog
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const token = "{{ token }}";
|
||||||
|
const button = document.getElementById("register-button");
|
||||||
|
const displayNameInput = document.getElementById("display-name");
|
||||||
|
const errorDiv = document.getElementById("register-error");
|
||||||
|
const loadingDiv = document.getElementById("register-loading");
|
||||||
|
const unsupportedDiv = document.getElementById("register-unsupported");
|
||||||
|
const formDiv = document.getElementById("register-form");
|
||||||
|
const successDiv = document.getElementById("register-success");
|
||||||
|
|
||||||
|
// Check WebAuthn support
|
||||||
|
if (!window.PublicKeyCredential) {
|
||||||
|
button.disabled = true;
|
||||||
|
unsupportedDiv.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener("click", async function () {
|
||||||
|
const displayName = displayNameInput.value.trim();
|
||||||
|
if (!displayName) {
|
||||||
|
errorDiv.textContent = "Please enter a display name.";
|
||||||
|
errorDiv.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDiv.classList.add("hidden");
|
||||||
|
loadingDiv.classList.remove("hidden");
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startPasskeyRegistration(token, displayName);
|
||||||
|
formDiv.classList.add("hidden");
|
||||||
|
successDiv.classList.remove("hidden");
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.textContent = err.message;
|
||||||
|
errorDiv.classList.remove("hidden");
|
||||||
|
loadingDiv.classList.add("hidden");
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
147
templates/webauthn.js
Normal file
147
templates/webauthn.js
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// Base64url encoding/decoding helpers for WebAuthn
|
||||||
|
function base64urlToBuffer(base64url) {
|
||||||
|
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64url(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert server challenge options to format navigator.credentials expects
|
||||||
|
function prepareCreationOptions(options) {
|
||||||
|
const publicKey = options.publicKey;
|
||||||
|
publicKey.challenge = base64urlToBuffer(publicKey.challenge);
|
||||||
|
publicKey.user.id = base64urlToBuffer(publicKey.user.id);
|
||||||
|
if (publicKey.excludeCredentials) {
|
||||||
|
publicKey.excludeCredentials = publicKey.excludeCredentials.map(function (cred) {
|
||||||
|
return Object.assign({}, cred, { id: base64urlToBuffer(cred.id) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareRequestOptions(options) {
|
||||||
|
const publicKey = options.publicKey;
|
||||||
|
publicKey.challenge = base64urlToBuffer(publicKey.challenge);
|
||||||
|
if (publicKey.allowCredentials) {
|
||||||
|
publicKey.allowCredentials = publicKey.allowCredentials.map(function (cred) {
|
||||||
|
return Object.assign({}, cred, { id: base64urlToBuffer(cred.id) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize credential for sending back to server
|
||||||
|
function serializeRegistrationCredential(credential) {
|
||||||
|
const response = credential.response;
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferToBase64url(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
attestationObject: bufferToBase64url(response.attestationObject),
|
||||||
|
clientDataJSON: bufferToBase64url(response.clientDataJSON),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAuthenticationCredential(credential) {
|
||||||
|
const response = credential.response;
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferToBase64url(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
authenticatorData: bufferToBase64url(response.authenticatorData),
|
||||||
|
clientDataJSON: bufferToBase64url(response.clientDataJSON),
|
||||||
|
signature: bufferToBase64url(response.signature),
|
||||||
|
userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start passkey registration ceremony
|
||||||
|
async function startPasskeyRegistration(token, displayName) {
|
||||||
|
// 1. Get challenge from server
|
||||||
|
const startResponse = await fetch("/api/v1/webauthn/register/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, display_name: displayName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!startResponse.ok) {
|
||||||
|
const status = startResponse.status;
|
||||||
|
if (status === 401) throw new Error("Invalid registration token.");
|
||||||
|
if (status === 410) throw new Error("Registration token has expired or already been used.");
|
||||||
|
throw new Error("Failed to start registration (HTTP " + status + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { challenge_id, options } = await startResponse.json();
|
||||||
|
|
||||||
|
// 2. Create credential via browser WebAuthn API
|
||||||
|
const creationOptions = prepareCreationOptions(options);
|
||||||
|
const credential = await navigator.credentials.create(creationOptions);
|
||||||
|
|
||||||
|
// 3. Send credential to server
|
||||||
|
const finishResponse = await fetch("/api/v1/webauthn/register/finish", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge_id,
|
||||||
|
credential: serializeRegistrationCredential(credential),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finishResponse.ok) {
|
||||||
|
throw new Error("Failed to complete registration (HTTP " + finishResponse.status + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start passkey authentication ceremony
|
||||||
|
async function startPasskeyAuthentication(queryParams) {
|
||||||
|
// 1. Get challenge from server
|
||||||
|
const url = "/api/v1/webauthn/auth/start" + (queryParams || "");
|
||||||
|
const startResponse = await fetch(url);
|
||||||
|
|
||||||
|
if (!startResponse.ok) {
|
||||||
|
const status = startResponse.status;
|
||||||
|
if (status === 404) throw new Error("No passkeys registered. Please register first.");
|
||||||
|
throw new Error("Failed to start authentication (HTTP " + status + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { challenge_id, options } = await startResponse.json();
|
||||||
|
|
||||||
|
// 2. Get assertion via browser WebAuthn API
|
||||||
|
const requestOptions = prepareRequestOptions(options);
|
||||||
|
const credential = await navigator.credentials.get(requestOptions);
|
||||||
|
|
||||||
|
// 3. Send assertion to server
|
||||||
|
const finishResponse = await fetch("/api/v1/webauthn/auth/finish", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge_id,
|
||||||
|
credential: serializeAuthenticationCredential(credential),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finishResponse.ok) {
|
||||||
|
throw new Error("Authentication failed (HTTP " + finishResponse.status + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishResponse.json();
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue