Security key & totp support

This commit is contained in:
lewis
2025-12-20 21:41:29 +02:00
parent 3177dc42f5
commit 45b6b58ad6
44 changed files with 5003 additions and 51 deletions
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'registration' AND expires_at > NOW()\n ORDER BY created_at DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "state_json",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports\n FROM passkeys\n WHERE did = $1\n ORDER BY created_at DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "credential_id",
"type_info": "Bytea"
},
{
"ordinal": 3,
"name": "public_key",
"type_info": "Bytea"
},
{
"ordinal": 4,
"name": "sign_count",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "last_used",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "friendly_name",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "aaguid",
"type_info": "Bytea"
},
{
"ordinal": 9,
"name": "transports",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)\n VALUES ($1, $2, $3, false, NOW())\n ON CONFLICT (did) DO UPDATE SET\n secret_encrypted = $2,\n encryption_version = $3,\n verified = false,\n created_at = NOW(),\n last_used = NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bytea",
"Int4"
]
},
"nullable": []
},
"hash": "2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM webauthn_challenges WHERE expires_at < NOW()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "secret_encrypted",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "encryption_version",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "verified",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'authentication', $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Bytea",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM user_totp WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name, aaguid)\n VALUES ($1, $2, $3, $4, 0, $5, $6)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Bytea",
"Bytea",
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb"
}
@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT two_factor_enabled, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", id FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "two_factor_enabled",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "preferred_comms_channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 2,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM backup_codes WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Bytea"
]
},
"nullable": []
},
"hash": "6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'authentication' AND expires_at > NOW()\n ORDER BY created_at DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "state_json",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'authentication'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT did FROM users WHERE handle = $1 OR email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports\n FROM passkeys\n WHERE credential_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "credential_id",
"type_info": "Bytea"
},
{
"ordinal": 3,
"name": "public_key",
"type_info": "Bytea"
},
{
"ordinal": 4,
"name": "sign_count",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "last_used",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "friendly_name",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "aaguid",
"type_info": "Bytea"
},
{
"ordinal": 9,
"name": "transports",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'registration'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_hash FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "code_hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE user_totp SET last_used = NOW() WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT did, deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "deactivated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "takedown_ref",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "email_verified",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "discord_verified",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "telegram_verified",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "signal_verified",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
false
]
},
"hash": "e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT verified FROM user_totp WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "verified",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE backup_codes SET used_at = $1 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Uuid"
]
},
"nullable": []
},
"hash": "e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'registration', $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Bytea",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af"
}
Generated
+227
View File
@@ -116,6 +116,45 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[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.111",
"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.111",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -777,6 +816,17 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "bcrypt"
version = "0.17.1"
@@ -1216,6 +1266,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "cordyceps"
version = "0.3.4"
@@ -1560,6 +1616,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.5"
@@ -3872,6 +3942,15 @@ dependencies = [
"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]]
name = "once_cell"
version = "1.21.3"
@@ -4047,6 +4126,12 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pem"
version = "3.0.6"
@@ -4357,6 +4442,23 @@ dependencies = [
"num-traits",
]
[[package]]
name = "qrcodegen"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
[[package]]
name = "qrcodegen-image"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "221b7eace1aef8c95d65dbe09fb7a1a43d006045394a89afba6997721fcb7708"
dependencies = [
"base64 0.22.1",
"image",
"qrcodegen",
]
[[package]]
name = "quanta"
version = "0.12.6"
@@ -4745,6 +4847,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "1.1.2"
@@ -5036,6 +5147,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_cbor_2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
dependencies = [
"half",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -6039,6 +6160,22 @@ dependencies = [
"tonic",
]
[[package]]
name = "totp-rs"
version = "5.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72"
dependencies = [
"base32",
"constant_time_eq",
"hmac",
"qrcodegen-image",
"sha1",
"sha2",
"url",
"urlencoding",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -6226,11 +6363,14 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
"totp-rs",
"tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
"webauthn-rs",
"webauthn-rs-proto",
"wiremock",
]
@@ -6416,6 +6556,8 @@ dependencies = [
"getrandom 0.3.4",
"js-sys",
"rand 0.9.2",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
@@ -6574,6 +6716,74 @@ dependencies = [
"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-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]]
name = "webpage"
version = "2.0.1"
@@ -7083,6 +7293,23 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "xattr"
version = "1.6.1"
+4 -1
View File
@@ -47,7 +47,7 @@ tracing = "0.1.43"
tracing-subscriber = "0.3.22"
tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] }
urlencoding = "2.1"
uuid = { version = "1.19.0", features = ["v4", "fast-rng"] }
uuid = { version = "1.19.0", features = ["v4", "v5", "fast-rng"] }
iroh-car = "0.5.1"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
@@ -56,6 +56,9 @@ hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
metrics = "0.24"
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
bs58 = "0.5.1"
totp-rs = { version = "5", features = ["qr"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
webauthn-rs-proto = "0.5.4"
[features]
external-infra = []
[dev-dependencies]
+6
View File
@@ -17,7 +17,9 @@
import OAuthLogin from './routes/OAuthLogin.svelte'
import OAuthAccounts from './routes/OAuthAccounts.svelte'
import OAuth2FA from './routes/OAuth2FA.svelte'
import OAuthTotp from './routes/OAuthTotp.svelte'
import OAuthError from './routes/OAuthError.svelte'
import Security from './routes/Security.svelte'
const auth = getAuthState()
@@ -59,8 +61,12 @@
return OAuthAccounts
case '/oauth/2fa':
return OAuth2FA
case '/oauth/totp':
return OAuthTotp
case '/oauth/error':
return OAuthError
case '/security':
return Security
default:
return auth.session ? Dashboard : Login
}
+76
View File
@@ -493,4 +493,80 @@ export const api = {
body: { repo, collection, rkey },
})
},
async getTotpStatus(token: string): Promise<{ enabled: boolean; hasBackupCodes: boolean }> {
return xrpc('com.atproto.server.getTotpStatus', { token })
},
async createTotpSecret(token: string): Promise<{ uri: string; qrBase64: string }> {
return xrpc('com.atproto.server.createTotpSecret', { method: 'POST', token })
},
async enableTotp(token: string, code: string): Promise<{ success: boolean; backupCodes: string[] }> {
return xrpc('com.atproto.server.enableTotp', {
method: 'POST',
token,
body: { code },
})
},
async disableTotp(token: string, password: string, code: string): Promise<{ success: boolean }> {
return xrpc('com.atproto.server.disableTotp', {
method: 'POST',
token,
body: { password, code },
})
},
async regenerateBackupCodes(token: string, password: string, code: string): Promise<{ backupCodes: string[] }> {
return xrpc('com.atproto.server.regenerateBackupCodes', {
method: 'POST',
token,
body: { password, code },
})
},
async startPasskeyRegistration(token: string, friendlyName?: string): Promise<{ options: unknown }> {
return xrpc('com.atproto.server.startPasskeyRegistration', {
method: 'POST',
token,
body: { friendlyName },
})
},
async finishPasskeyRegistration(token: string, credential: unknown, friendlyName?: string): Promise<{ id: string; credentialId: string }> {
return xrpc('com.atproto.server.finishPasskeyRegistration', {
method: 'POST',
token,
body: { credential, friendlyName },
})
},
async listPasskeys(token: string): Promise<{
passkeys: Array<{
id: string
credentialId: string
friendlyName: string | null
createdAt: string
lastUsed: string | null
}>
}> {
return xrpc('com.atproto.server.listPasskeys', { token })
},
async deletePasskey(token: string, id: string): Promise<void> {
await xrpc('com.atproto.server.deletePasskey', {
method: 'POST',
token,
body: { id },
})
},
async updatePasskey(token: string, id: string, friendlyName: string): Promise<void> {
await xrpc('com.atproto.server.updatePasskey', {
method: 'POST',
token,
body: { id, friendlyName },
})
},
}
+4
View File
@@ -155,6 +155,10 @@
<h3>Account Settings</h3>
<p>Email, password, handle, and more</p>
</a>
<a href="#/security" class="nav-card">
<h3>Security</h3>
<p>Two-factor authentication</p>
</a>
<a href="#/notifications" class="nav-card">
<h3>Notification Preferences</h3>
<p>Discord, Telegram, Signal channels</p>
+5
View File
@@ -73,6 +73,11 @@
return
}
if (data.needs_totp) {
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
return
+308 -1
View File
@@ -6,6 +6,16 @@
let rememberDevice = $state(false)
let submitting = $state(false)
let error = $state<string | null>(null)
let hasPasskeys = $state(false)
let hasTotp = $state(false)
let checkingSecurityStatus = $state(false)
let securityStatusChecked = $state(false)
let passkeySupported = $state(false)
let clientName = $state<string | null>(null)
$effect(() => {
passkeySupported = window.PublicKeyCredential !== undefined
})
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
@@ -24,6 +34,200 @@
}
})
$effect(() => {
fetchAuthRequestInfo()
})
async function fetchAuthRequestInfo() {
const requestUri = getRequestUri()
if (!requestUri) return
try {
const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
if (data.login_hint && !username) {
username = data.login_hint
}
if (data.client_name) {
clientName = data.client_name
}
}
} catch {
// Ignore errors fetching auth info
}
}
let checkTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
if (checkTimeout) {
clearTimeout(checkTimeout)
}
hasPasskeys = false
hasTotp = false
securityStatusChecked = false
if (username.length >= 3) {
checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500)
}
})
async function checkUserSecurityStatus() {
if (!username || checkingSecurityStatus) return
checkingSecurityStatus = true
try {
const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`)
if (response.ok) {
const data = await response.json()
hasPasskeys = passkeySupported && data.hasPasskeys === true
hasTotp = data.hasTotp === true
securityStatusChecked = true
}
} catch {
hasPasskeys = false
hasTotp = false
} finally {
checkingSecurityStatus = false
}
}
async function handlePasskeyLogin() {
const requestUri = getRequestUri()
if (!requestUri || !username) {
error = 'Missing required parameters'
return
}
submitting = true
error = null
try {
const startResponse = await fetch('/oauth/passkey/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
identifier: username
})
})
if (!startResponse.ok) {
const data = await startResponse.json()
error = data.error_description || data.error || 'Failed to start passkey login'
submitting = false
return
}
const { options } = await startResponse.json()
const credential = await navigator.credentials.get({
publicKey: prepareCredentialRequestOptions(options.publicKey)
}) as PublicKeyCredential | null
if (!credential) {
error = 'Passkey authentication was cancelled'
submitting = false
return
}
const assertionResponse = credential.response as AuthenticatorAssertionResponse
const credentialData = {
id: credential.id,
type: credential.type,
rawId: arrayBufferToBase64Url(credential.rawId),
response: {
clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON),
authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData),
signature: arrayBufferToBase64Url(assertionResponse.signature),
userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null
}
}
const finishResponse = await fetch('/oauth/passkey/finish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
credential: credentialData
})
})
const data = await finishResponse.json()
if (!finishResponse.ok) {
error = data.error_description || data.error || 'Passkey authentication failed'
submitting = false
return
}
if (data.needs_totp) {
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = 'Unexpected response from server'
submitting = false
} catch (e) {
console.error('Passkey login error:', e)
if (e instanceof DOMException && e.name === 'NotAllowedError') {
error = 'Passkey authentication was cancelled'
} else {
error = `Failed to authenticate with passkey: ${e instanceof Error ? e.message : String(e)}`
}
submitting = false
}
}
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
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 prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions {
return {
...options,
challenge: base64UrlToArrayBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
@@ -58,6 +262,11 @@
return
}
if (data.needs_totp) {
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
return
@@ -106,7 +315,13 @@
<div class="oauth-login-container">
<h1>Sign In</h1>
<p class="subtitle">Sign in to continue to the application</p>
<p class="subtitle">
{#if clientName}
Sign in to continue to <strong>{clientName}</strong>
{:else}
Sign in to continue to the application
{/if}
</p>
{#if error}
<div class="error">{error}</div>
@@ -126,6 +341,36 @@
/>
</div>
{#if securityStatusChecked && passkeySupported}
<button
type="button"
class="passkey-btn"
class:passkey-unavailable={!hasPasskeys}
onclick={handlePasskeyLogin}
disabled={submitting || !hasPasskeys || !username}
title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'}
>
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
<path d="M17 17v4l3-2-3-2z" />
<path d="M12 11c-4 0-6 2-6 4v4h9" />
</svg>
<span class="passkey-text">
{#if submitting}
Authenticating...
{:else if hasPasskeys}
Sign in with passkey
{:else}
Passkey not set up
{/if}
</span>
</button>
<div class="auth-divider">
<span>or use password</span>
</div>
{/if}
<div class="field">
<label for="password">Password</label>
<input
@@ -266,4 +511,66 @@
.submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.auth-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 0.5rem 0;
}
.auth-divider::before,
.auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-color-light);
}
.auth-divider span {
color: var(--text-secondary);
font-size: 0.875rem;
}
.passkey-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
background: var(--accent);
color: white;
border: 1px solid var(--accent);
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s, opacity 0.15s;
}
.passkey-btn:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.passkey-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.passkey-btn.passkey-unavailable {
background: var(--bg-secondary);
color: var(--text-secondary);
border-color: var(--border-color);
}
.passkey-icon {
width: 20px;
height: 20px;
}
.passkey-text {
flex: 1;
text-align: left;
}
</style>
+225
View File
@@ -0,0 +1,225 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
let code = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('request_uri')
}
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
if (!requestUri) {
error = 'Missing request_uri parameter'
return
}
submitting = true
error = null
try {
const response = await fetch('/oauth/authorize/2fa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
code: code.trim().toUpperCase()
})
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || 'Verification failed'
submitting = false
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = 'Unexpected response from server'
submitting = false
} catch {
error = 'Failed to connect to server'
submitting = false
}
}
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
} else {
window.history.back()
}
}
let isBackupCode = $derived(code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim()))
let isTotpCode = $derived(code.trim().length === 6 && /^[0-9]+$/.test(code.trim()))
let canSubmit = $derived(isBackupCode || isTotpCode)
</script>
<div class="oauth-totp-container">
<h1>Two-Factor Authentication</h1>
<p class="subtitle">
Enter the 6-digit code from your authenticator app, or use a backup code.
</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="field">
<label for="code">Verification Code</label>
<input
id="code"
type="text"
bind:value={code}
placeholder="Enter code"
disabled={submitting}
required
maxlength="8"
autocomplete="one-time-code"
autocapitalize="characters"
/>
<p class="hint">
{#if isBackupCode}
Using backup code
{:else if isTotpCode}
Using authenticator code
{:else}
6 digits for authenticator, 8 characters for backup code
{/if}
</p>
</div>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
Cancel
</button>
<button type="submit" class="submit-btn" disabled={submitting || !canSubmit}>
{submitting ? 'Verifying...' : 'Verify'}
</button>
</div>
</form>
</div>
<style>
.oauth-totp-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
}
h1 {
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 2rem 0;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
}
input {
padding: 0.75rem;
border: 1px solid var(--border-color-light);
border-radius: 4px;
font-size: 1.5rem;
letter-spacing: 0.25em;
text-align: center;
background: var(--bg-input);
color: var(--text-primary);
text-transform: uppercase;
}
input:focus {
outline: none;
border-color: var(--accent);
}
.hint {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0.25rem 0 0 0;
text-align: center;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
margin-bottom: 1rem;
}
.actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.actions button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.15s;
}
.actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.cancel-btn:hover:not(:disabled) {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.submit-btn {
background: var(--accent);
color: white;
}
.submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
</style>
+897
View File
@@ -0,0 +1,897 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
const auth = getAuthState()
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
let loading = $state(true)
let totpEnabled = $state(false)
let hasBackupCodes = $state(false)
let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle')
let qrBase64 = $state('')
let totpUri = $state('')
let verifyCodeRaw = $state('')
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
let verifyLoading = $state(false)
let backupCodes = $state<string[]>([])
let disablePassword = $state('')
let disableCode = $state('')
let disableLoading = $state(false)
let showDisableForm = $state(false)
let regenPassword = $state('')
let regenCode = $state('')
let regenLoading = $state(false)
let showRegenForm = $state(false)
interface Passkey {
id: string
credentialId: string
friendlyName: string | null
createdAt: string
lastUsed: string | null
}
let passkeys = $state<Passkey[]>([])
let passkeysLoading = $state(true)
let addingPasskey = $state(false)
let newPasskeyName = $state('')
let editingPasskeyId = $state<string | null>(null)
let editPasskeyName = $state('')
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
$effect(() => {
if (auth.session) {
loadTotpStatus()
loadPasskeys()
}
})
async function loadTotpStatus() {
if (!auth.session) return
loading = true
try {
const status = await api.getTotpStatus(auth.session.accessJwt)
totpEnabled = status.enabled
hasBackupCodes = status.hasBackupCodes
} catch {
showMessage('error', 'Failed to load TOTP status')
} finally {
loading = false
}
}
function showMessage(type: 'success' | 'error', text: string) {
message = { type, text }
setTimeout(() => {
if (message?.text === text) message = null
}, 5000)
}
async function handleStartSetup() {
if (!auth.session) return
verifyLoading = true
try {
const result = await api.createTotpSecret(auth.session.accessJwt)
qrBase64 = result.qrBase64
totpUri = result.uri
setupStep = 'qr'
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
} finally {
verifyLoading = false
}
}
async function handleVerifySetup(e: Event) {
e.preventDefault()
if (!auth.session || !verifyCode) return
verifyLoading = true
try {
const result = await api.enableTotp(auth.session.accessJwt, verifyCode)
backupCodes = result.backupCodes
setupStep = 'backup'
totpEnabled = true
hasBackupCodes = true
verifyCodeRaw = ''
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
} finally {
verifyLoading = false
}
}
function handleFinishSetup() {
setupStep = 'idle'
backupCodes = []
qrBase64 = ''
totpUri = ''
showMessage('success', 'Two-factor authentication enabled successfully')
}
async function handleDisable(e: Event) {
e.preventDefault()
if (!auth.session || !disablePassword || !disableCode) return
disableLoading = true
try {
await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode)
totpEnabled = false
hasBackupCodes = false
showDisableForm = false
disablePassword = ''
disableCode = ''
showMessage('success', 'Two-factor authentication disabled')
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP')
} finally {
disableLoading = false
}
}
async function handleRegenerate(e: Event) {
e.preventDefault()
if (!auth.session || !regenPassword || !regenCode) return
regenLoading = true
try {
const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode)
backupCodes = result.backupCodes
setupStep = 'backup'
showRegenForm = false
regenPassword = ''
regenCode = ''
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
} finally {
regenLoading = false
}
}
function copyBackupCodes() {
const text = backupCodes.join('\n')
navigator.clipboard.writeText(text)
showMessage('success', 'Backup codes copied to clipboard')
}
async function loadPasskeys() {
if (!auth.session) return
passkeysLoading = true
try {
const result = await api.listPasskeys(auth.session.accessJwt)
passkeys = result.passkeys
} catch {
showMessage('error', 'Failed to load passkeys')
} finally {
passkeysLoading = false
}
}
async function handleAddPasskey() {
if (!auth.session) return
if (!window.PublicKeyCredential) {
showMessage('error', 'Passkeys are not supported in this browser')
return
}
addingPasskey = true
try {
const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined)
const publicKeyOptions = preparePublicKeyOptions(options)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
})
if (!credential) {
showMessage('error', 'Passkey creation was cancelled')
return
}
const credentialResponse = {
id: credential.id,
type: credential.type,
rawId: arrayBufferToBase64Url((credential as PublicKeyCredential).rawId),
response: {
clientDataJSON: arrayBufferToBase64Url((credential as PublicKeyCredential).response.clientDataJSON),
attestationObject: arrayBufferToBase64Url(((credential as PublicKeyCredential).response as AuthenticatorAttestationResponse).attestationObject),
},
}
await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined)
await loadPasskeys()
newPasskeyName = ''
showMessage('success', 'Passkey added successfully')
} catch (e) {
if (e instanceof DOMException && e.name === 'NotAllowedError') {
showMessage('error', 'Passkey creation was cancelled')
} else {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey')
}
} finally {
addingPasskey = false
}
}
async function handleDeletePasskey(id: string) {
if (!auth.session) return
if (!confirm('Are you sure you want to delete this passkey?')) return
try {
await api.deletePasskey(auth.session.accessJwt, id)
await loadPasskeys()
showMessage('success', 'Passkey deleted')
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey')
}
}
async function handleSavePasskeyName() {
if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return
try {
await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim())
await loadPasskeys()
editingPasskeyId = null
editPasskeyName = ''
showMessage('success', 'Passkey renamed')
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey')
}
}
function startEditPasskey(passkey: Passkey) {
editingPasskeyId = passkey.id
editPasskeyName = passkey.friendlyName || ''
}
function cancelEditPasskey() {
editingPasskeyId = null
editPasskeyName = ''
}
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
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 preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions {
return {
...options.publicKey,
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
user: {
...options.publicKey.user,
id: base64UrlToArrayBuffer(options.publicKey.user.id)
},
excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToArrayBuffer(cred.id)
})) || []
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString()
}
</script>
<div class="page">
<header>
<a href="#/dashboard" class="back">&larr; Dashboard</a>
<h1>Security Settings</h1>
</header>
{#if message}
<div class="message {message.type}">{message.text}</div>
{/if}
{#if loading}
<div class="loading">Loading...</div>
{:else}
<section>
<h2>Two-Factor Authentication</h2>
<p class="description">
Add an extra layer of security to your account using an authenticator app like Google Authenticator, Authy, or 1Password.
</p>
{#if setupStep === 'idle'}
{#if totpEnabled}
<div class="status enabled">
<span>Two-factor authentication is <strong>enabled</strong></span>
</div>
{#if !showDisableForm && !showRegenForm}
<div class="totp-actions">
<button type="button" class="secondary" onclick={() => showRegenForm = true}>
Regenerate Backup Codes
</button>
<button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
Disable 2FA
</button>
</div>
{/if}
{#if showRegenForm}
<form onsubmit={handleRegenerate} class="inline-form">
<h3>Regenerate Backup Codes</h3>
<p class="warning-text">This will invalidate all existing backup codes.</p>
<div class="field">
<label for="regen-password">Password</label>
<input
id="regen-password"
type="password"
bind:value={regenPassword}
placeholder="Enter your password"
disabled={regenLoading}
required
/>
</div>
<div class="field">
<label for="regen-code">Authenticator Code</label>
<input
id="regen-code"
type="text"
bind:value={regenCode}
placeholder="6-digit code"
disabled={regenLoading}
required
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
Cancel
</button>
<button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
{regenLoading ? 'Regenerating...' : 'Regenerate'}
</button>
</div>
</form>
{/if}
{#if showDisableForm}
<form onsubmit={handleDisable} class="inline-form danger-form">
<h3>Disable Two-Factor Authentication</h3>
<p class="warning-text">This will make your account less secure.</p>
<div class="field">
<label for="disable-password">Password</label>
<input
id="disable-password"
type="password"
bind:value={disablePassword}
placeholder="Enter your password"
disabled={disableLoading}
required
/>
</div>
<div class="field">
<label for="disable-code">Authenticator Code</label>
<input
id="disable-code"
type="text"
bind:value={disableCode}
placeholder="6-digit code"
disabled={disableLoading}
required
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
Cancel
</button>
<button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
{disableLoading ? 'Disabling...' : 'Disable 2FA'}
</button>
</div>
</form>
{/if}
{:else}
<div class="status disabled">
<span>Two-factor authentication is <strong>not enabled</strong></span>
</div>
<button onclick={handleStartSetup} disabled={verifyLoading}>
{verifyLoading ? 'Setting up...' : 'Set Up Two-Factor Authentication'}
</button>
{/if}
{:else if setupStep === 'qr'}
<div class="setup-step">
<h3>Step 1: Scan QR Code</h3>
<p>Scan this QR code with your authenticator app:</p>
<div class="qr-container">
<img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" />
</div>
<details class="manual-entry">
<summary>Can't scan? Enter manually</summary>
<code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
</details>
<button onclick={() => setupStep = 'verify'}>
Next: Verify Code
</button>
</div>
{:else if setupStep === 'verify'}
<div class="setup-step">
<h3>Step 2: Verify Setup</h3>
<p>Enter the 6-digit code from your authenticator app:</p>
<form onsubmit={handleVerifySetup}>
<div class="field">
<input
type="text"
bind:value={verifyCodeRaw}
placeholder="000000"
disabled={verifyLoading}
inputmode="numeric"
class="code-input"
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}>
Back
</button>
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>
{verifyLoading ? 'Verifying...' : 'Verify & Enable'}
</button>
</div>
</form>
</div>
{:else if setupStep === 'backup'}
<div class="setup-step">
<h3>Step 3: Save Backup Codes</h3>
<p class="warning-text">
Save these backup codes in a secure location. Each code can only be used once.
If you lose access to your authenticator app, you'll need these to sign in.
</p>
<div class="backup-codes">
{#each backupCodes as code}
<code class="backup-code">{code}</code>
{/each}
</div>
<div class="actions">
<button type="button" class="secondary" onclick={copyBackupCodes}>
Copy to Clipboard
</button>
<button onclick={handleFinishSetup}>
I've Saved My Codes
</button>
</div>
</div>
{/if}
</section>
<section>
<h2>Passkeys</h2>
<p class="description">
Passkeys are a secure, passwordless way to sign in using biometrics (fingerprint or face), a security key, or your device's screen lock.
</p>
{#if passkeysLoading}
<div class="loading">Loading passkeys...</div>
{:else}
{#if passkeys.length > 0}
<div class="passkey-list">
{#each passkeys as passkey}
<div class="passkey-item">
{#if editingPasskeyId === passkey.id}
<div class="passkey-edit">
<input
type="text"
bind:value={editPasskeyName}
placeholder="Passkey name"
class="passkey-name-input"
/>
<div class="passkey-edit-actions">
<button type="button" class="small" onclick={handleSavePasskeyName}>Save</button>
<button type="button" class="small secondary" onclick={cancelEditPasskey}>Cancel</button>
</div>
</div>
{:else}
<div class="passkey-info">
<span class="passkey-name">{passkey.friendlyName || 'Unnamed passkey'}</span>
<span class="passkey-meta">
Added {formatDate(passkey.createdAt)}
{#if passkey.lastUsed}
&middot; Last used {formatDate(passkey.lastUsed)}
{/if}
</span>
</div>
<div class="passkey-actions">
<button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}>
Rename
</button>
<button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
Delete
</button>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="status disabled">
<span>No passkeys registered</span>
</div>
{/if}
<div class="add-passkey">
<div class="field">
<label for="passkey-name">Passkey Name (optional)</label>
<input
id="passkey-name"
type="text"
bind:value={newPasskeyName}
placeholder="e.g., MacBook Touch ID"
disabled={addingPasskey}
/>
</div>
<button onclick={handleAddPasskey} disabled={addingPasskey}>
{addingPasskey ? 'Adding Passkey...' : 'Add a Passkey'}
</button>
</div>
{/if}
</section>
{/if}
</div>
<style>
.page {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
}
.back {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
}
.back:hover {
color: var(--accent);
}
h1 {
margin: 0.5rem 0 0 0;
}
.message {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.message.success {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
.message.error {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
}
.loading {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
section {
padding: 1.5rem;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 1.5rem;
}
section h2 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
}
.description {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.status.enabled {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
.status.disabled {
background: var(--warning-bg);
border: 1px solid var(--border-color);
color: var(--warning-text);
}
.totp-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color-light);
border-radius: 4px;
font-size: 1rem;
box-sizing: border-box;
background: var(--bg-input);
color: var(--text-primary);
}
input:focus {
outline: none;
border-color: var(--accent);
}
.code-input {
font-size: 1.5rem;
letter-spacing: 0.5em;
text-align: center;
max-width: 200px;
margin: 0 auto;
display: block;
}
button {
padding: 0.75rem 1.5rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover:not(:disabled) {
background: var(--accent-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.secondary {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color-light);
}
button.secondary:hover:not(:disabled) {
background: var(--bg-card);
}
button.danger {
background: var(--error-text);
}
button.danger:hover:not(:disabled) {
background: #900;
}
button.danger-outline {
background: transparent;
color: var(--error-text);
border: 1px solid var(--error-border);
}
button.danger-outline:hover:not(:disabled) {
background: var(--error-bg);
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.inline-form {
margin-top: 1rem;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color-light);
border-radius: 6px;
}
.inline-form h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.danger-form {
border-color: var(--error-border);
background: var(--error-bg);
}
.warning-text {
color: var(--error-text);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.setup-step {
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color-light);
border-radius: 6px;
}
.setup-step h3 {
margin: 0 0 0.5rem 0;
}
.setup-step p {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.qr-container {
display: flex;
justify-content: center;
margin: 1.5rem 0;
}
.qr-code {
width: 200px;
height: 200px;
image-rendering: pixelated;
}
.manual-entry {
margin-bottom: 1rem;
font-size: 0.875rem;
}
.manual-entry summary {
cursor: pointer;
color: var(--accent);
}
.secret-code {
display: block;
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--bg-input);
border-radius: 4px;
word-break: break-all;
font-size: 0.75rem;
}
.backup-codes {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
margin: 1rem 0;
}
.backup-code {
padding: 0.5rem;
background: var(--bg-input);
border-radius: 4px;
text-align: center;
font-size: 0.875rem;
font-family: monospace;
}
.passkey-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.passkey-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border-color-light);
border-radius: 6px;
gap: 1rem;
}
.passkey-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.passkey-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.passkey-meta {
font-size: 0.75rem;
color: var(--text-secondary);
}
.passkey-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.passkey-edit {
display: flex;
flex: 1;
gap: 0.5rem;
align-items: center;
}
.passkey-name-input {
flex: 1;
padding: 0.5rem;
font-size: 0.875rem;
}
.passkey-edit-actions {
display: flex;
gap: 0.25rem;
}
button.small {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.add-passkey {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color-light);
}
.add-passkey .field {
margin-bottom: 0.75rem;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
CREATE TABLE user_totp (
did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE,
secret_encrypted BYTEA NOT NULL,
encryption_version INTEGER NOT NULL DEFAULT 1,
verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE TABLE backup_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_backup_codes_did ON backup_codes(did);
CREATE TABLE passkeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ,
friendly_name TEXT,
aaguid BYTEA,
transports TEXT[]
);
CREATE INDEX idx_passkeys_did ON passkeys(did);
CREATE TABLE webauthn_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL,
challenge BYTEA NOT NULL,
challenge_type TEXT NOT NULL,
state_json TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
+10
View File
@@ -3,10 +3,12 @@ pub mod app_password;
pub mod email;
pub mod invite;
pub mod meta;
pub mod passkeys;
pub mod password;
pub mod service_auth;
pub mod session;
pub mod signing_key;
pub mod totp;
pub use account_status::{
activate_account, check_account_status, deactivate_account, delete_account,
@@ -16,6 +18,10 @@ pub use app_password::{create_app_password, list_app_passwords, revoke_app_passw
pub use email::{confirm_email, request_email_update, update_email};
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use meta::{describe_server, health, robots_txt};
pub use passkeys::{
delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys,
start_passkey_registration, update_passkey,
};
pub use password::{change_password, request_password_reset, reset_password};
pub use service_auth::get_service_auth;
pub use session::{
@@ -23,3 +29,7 @@ pub use session::{
resend_verification, revoke_session,
};
pub use signing_key::reserve_signing_key;
pub use totp::{
create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled,
regenerate_backup_codes, verify_totp_or_backup_for_user,
};
+377
View File
@@ -0,0 +1,377 @@
use crate::auth::BearerAuth;
use crate::auth::webauthn::{
self, WebAuthnConfig, delete_passkey as db_delete_passkey, delete_registration_state,
get_passkeys_for_user, load_registration_state, save_passkey, save_registration_state,
update_passkey_name as db_update_passkey_name,
};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use webauthn_rs::prelude::*;
fn get_webauthn() -> Result<WebAuthnConfig, (StatusCode, Json<serde_json::Value>)> {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
WebAuthnConfig::new(&hostname).map_err(|e| {
error!("Failed to create WebAuthn config: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "WebAuthn configuration failed"})),
)
})
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartRegistrationInput {
pub friendly_name: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartRegistrationResponse {
pub options: serde_json::Value,
}
pub async fn start_passkey_registration(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<StartRegistrationInput>,
) -> Response {
let webauthn = match get_webauthn() {
Ok(w) => w,
Err(e) => return e.into_response(),
};
let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
let handle = match user {
Ok(Some(row)) => row.handle,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let existing_passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await {
Ok(passkeys) => passkeys,
Err(e) => {
error!("DB error fetching existing passkeys: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let exclude_credentials: Vec<CredentialID> = existing_passkeys
.iter()
.map(|p| CredentialID::from(p.credential_id.clone()))
.collect();
let display_name = input.friendly_name.as_deref().unwrap_or(&handle);
let (ccr, reg_state) = match webauthn.start_registration(
&auth.0.did,
&handle,
display_name,
exclude_credentials,
) {
Ok(result) => result,
Err(e) => {
error!("Failed to start passkey registration: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to start registration"})),
)
.into_response();
}
};
if let Err(e) = save_registration_state(&state.db, &auth.0.did, &reg_state).await {
error!("Failed to save registration state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
info!(did = %auth.0.did, "Passkey registration started");
Json(StartRegistrationResponse { options }).into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FinishRegistrationInput {
pub credential: serde_json::Value,
pub friendly_name: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FinishRegistrationResponse {
pub id: String,
pub credential_id: String,
}
pub async fn finish_passkey_registration(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<FinishRegistrationInput>,
) -> Response {
let webauthn = match get_webauthn() {
Ok(w) => w,
Err(e) => return e.into_response(),
};
let reg_state = match load_registration_state(&state.db, &auth.0.did).await {
Ok(Some(state)) => state,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoRegistrationInProgress",
"message": "No registration in progress. Call startPasskeyRegistration first."
})),
)
.into_response();
}
Err(e) => {
error!("DB error loading registration state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let credential: RegisterPublicKeyCredential = match serde_json::from_value(input.credential) {
Ok(c) => c,
Err(e) => {
warn!("Failed to parse credential: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidCredential",
"message": "Failed to parse credential response"
})),
)
.into_response();
}
};
let passkey = match webauthn.finish_registration(&credential, &reg_state) {
Ok(pk) => pk,
Err(e) => {
warn!("Failed to finish passkey registration: {}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "RegistrationFailed",
"message": "Failed to verify passkey registration"
})),
)
.into_response();
}
};
let passkey_id = match save_passkey(
&state.db,
&auth.0.did,
&passkey,
input.friendly_name.as_deref(),
)
.await
{
Ok(id) => id,
Err(e) => {
error!("Failed to save passkey: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = delete_registration_state(&state.db, &auth.0.did).await {
warn!("Failed to delete registration state: {:?}", e);
}
let credential_id_base64 = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
passkey.cred_id(),
);
info!(did = %auth.0.did, passkey_id = %passkey_id, "Passkey registered");
Json(FinishRegistrationResponse {
id: passkey_id.to_string(),
credential_id: credential_id_base64,
})
.into_response()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyInfo {
pub id: String,
pub credential_id: String,
pub friendly_name: Option<String>,
pub created_at: String,
pub last_used: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListPasskeysResponse {
pub passkeys: Vec<PasskeyInfo>,
}
pub async fn list_passkeys(State(state): State<AppState>, auth: BearerAuth) -> Response {
let passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await {
Ok(pks) => pks,
Err(e) => {
error!("DB error fetching passkeys: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let passkey_infos: Vec<PasskeyInfo> = passkeys
.into_iter()
.map(|pk| PasskeyInfo {
id: pk.id.to_string(),
credential_id: pk.credential_id_base64(),
friendly_name: pk.friendly_name,
created_at: pk.created_at.to_rfc3339(),
last_used: pk.last_used.map(|dt| dt.to_rfc3339()),
})
.collect();
Json(ListPasskeysResponse {
passkeys: passkey_infos,
})
.into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeletePasskeyInput {
pub id: String,
}
pub async fn delete_passkey(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<DeletePasskeyInput>,
) -> Response {
let id: uuid::Uuid = match input.id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
)
.into_response();
}
};
match db_delete_passkey(&state.db, id, &auth.0.did).await {
Ok(true) => {
info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted");
(StatusCode::OK, Json(json!({}))).into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
)
.into_response(),
Err(e) => {
error!("DB error deleting passkey: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePasskeyInput {
pub id: String,
pub friendly_name: String,
}
pub async fn update_passkey(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<UpdatePasskeyInput>,
) -> Response {
let id: uuid::Uuid = match input.id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
)
.into_response();
}
};
match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await {
Ok(true) => {
info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed");
(StatusCode::OK, Json(json!({}))).into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
)
.into_response(),
Err(e) => {
error!("DB error updating passkey: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool {
webauthn::has_passkeys(&state.db, did)
.await
.unwrap_or(false)
}
+749
View File
@@ -0,0 +1,749 @@
use crate::auth::BearerAuth;
use crate::auth::totp::{
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
verify_backup_code, verify_totp_code,
};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
const ENCRYPTION_VERSION: i32 = 1;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTotpSecretResponse {
pub secret: String,
pub uri: String,
pub qr_base64: String,
}
pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
if let Ok(Some(true)) = existing {
return (
StatusCode::CONFLICT,
Json(json!({
"error": "TotpAlreadyEnabled",
"message": "TOTP is already enabled for this account"
})),
)
.into_response();
}
let secret = generate_totp_secret();
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
let handle = match handle {
Ok(Some(h)) => h,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error fetching handle: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let uri = generate_totp_uri(&secret, &handle, &hostname);
let qr_code = match generate_qr_png_base64(&secret, &handle, &hostname) {
Ok(qr) => qr,
Err(e) => {
error!("Failed to generate QR code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})),
)
.into_response();
}
};
let encrypted_secret = match encrypt_totp_secret(&secret) {
Ok(enc) => enc,
Err(e) => {
error!("Failed to encrypt TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let result = sqlx::query!(
r#"
INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)
VALUES ($1, $2, $3, false, NOW())
ON CONFLICT (did) DO UPDATE SET
secret_encrypted = $2,
encryption_version = $3,
verified = false,
created_at = NOW(),
last_used = NULL
"#,
auth.0.did,
encrypted_secret,
ENCRYPTION_VERSION
)
.execute(&state.db)
.await;
if let Err(e) = result {
error!("Failed to store TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
info!(did = %auth.0.did, "TOTP secret created (pending verification)");
Json(CreateTotpSecretResponse {
secret: secret_base32,
uri,
qr_base64: qr_code,
})
.into_response()
}
#[derive(Deserialize)]
pub struct EnableTotpInput {
pub code: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnableTotpResponse {
pub backup_codes: Vec<String>,
}
pub async fn enable_totp(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<EnableTotpInput>,
) -> Response {
let totp_row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
auth.0.did
)
.fetch_optional(&state.db)
.await;
let totp_row = match totp_row {
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "TotpNotSetup",
"message": "Please call createTotpSecret first"
})),
)
.into_response();
}
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if totp_row.verified {
return (
StatusCode::CONFLICT,
Json(json!({
"error": "TotpAlreadyEnabled",
"message": "TOTP is already enabled"
})),
)
.into_response();
}
let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
{
Ok(s) => s,
Err(e) => {
error!("Failed to decrypt TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let code = input.code.trim();
if !verify_totp_code(&secret, code) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidCode",
"message": "Invalid verification code"
})),
)
.into_response();
}
let backup_codes = generate_backup_codes();
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query!(
"UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
auth.0.did
)
.execute(&mut *tx)
.await
{
error!("Failed to enable TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
.execute(&mut *tx)
.await
{
error!("Failed to clear old backup codes: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
for code in &backup_codes {
let hash = match hash_backup_code(code) {
Ok(h) => h,
Err(e) => {
error!("Failed to hash backup code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
auth.0.did,
hash
)
.execute(&mut *tx)
.await
{
error!("Failed to store backup code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
if let Err(e) = tx.commit().await {
error!("Failed to commit transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
Json(EnableTotpResponse { backup_codes }).into_response()
}
#[derive(Deserialize)]
pub struct DisableTotpInput {
pub password: String,
pub code: String,
}
pub async fn disable_totp(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<DisableTotpInput>,
) -> Response {
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
if !password_valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidPassword",
"message": "Password is incorrect"
})),
)
.into_response();
}
let totp_row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
auth.0.did
)
.fetch_optional(&state.db)
.await;
let totp_row = match totp_row {
Ok(Some(row)) if row.verified => row,
Ok(Some(_)) | Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "TotpNotEnabled",
"message": "TOTP is not enabled for this account"
})),
)
.into_response();
}
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let code = input.code.trim();
let code_valid = if is_backup_code_format(code) {
verify_backup_code_for_user(&state, &auth.0.did, code).await
} else {
let secret =
match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) {
Ok(s) => s,
Err(e) => {
error!("Failed to decrypt TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
verify_totp_code(&secret, code)
};
if !code_valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidCode",
"message": "Invalid verification code"
})),
)
.into_response();
}
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did)
.execute(&mut *tx)
.await
{
error!("Failed to delete TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
.execute(&mut *tx)
.await
{
error!("Failed to delete backup codes: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = tx.commit().await {
error!("Failed to commit transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
info!(did = %auth.0.did, "TOTP disabled");
(StatusCode::OK, Json(json!({}))).into_response()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetTotpStatusResponse {
pub enabled: bool,
pub has_backup_codes: bool,
pub backup_codes_remaining: i64,
}
pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
let enabled = match totp_row {
Ok(Some(row)) => row.verified,
Ok(None) => false,
Err(e) => {
error!("DB error fetching TOTP status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let backup_count_row = sqlx::query!(
"SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
auth.0.did
)
.fetch_one(&state.db)
.await;
let backup_count = backup_count_row.map(|r| r.count.unwrap_or(0)).unwrap_or(0);
Json(GetTotpStatusResponse {
enabled,
has_backup_codes: backup_count > 0,
backup_codes_remaining: backup_count,
})
.into_response()
}
#[derive(Deserialize)]
pub struct RegenerateBackupCodesInput {
pub password: String,
pub code: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RegenerateBackupCodesResponse {
pub backup_codes: Vec<String>,
}
pub async fn regenerate_backup_codes(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<RegenerateBackupCodesInput>,
) -> Response {
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
if !password_valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidPassword",
"message": "Password is incorrect"
})),
)
.into_response();
}
let totp_row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
auth.0.did
)
.fetch_optional(&state.db)
.await;
let totp_row = match totp_row {
Ok(Some(row)) if row.verified => row,
Ok(Some(_)) | Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "TotpNotEnabled",
"message": "TOTP must be enabled to regenerate backup codes"
})),
)
.into_response();
}
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
{
Ok(s) => s,
Err(e) => {
error!("Failed to decrypt TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let code = input.code.trim();
if !verify_totp_code(&secret, code) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidCode",
"message": "Invalid verification code"
})),
)
.into_response();
}
let backup_codes = generate_backup_codes();
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
.execute(&mut *tx)
.await
{
error!("Failed to clear old backup codes: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
for code in &backup_codes {
let hash = match hash_backup_code(code) {
Ok(h) => h,
Err(e) => {
error!("Failed to hash backup code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
auth.0.did,
hash
)
.execute(&mut *tx)
.await
{
error!("Failed to store backup code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
if let Err(e) = tx.commit().await {
error!("Failed to commit transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
info!(did = %auth.0.did, "Backup codes regenerated");
Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
}
async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> bool {
let code = code.trim().to_uppercase();
let backup_codes = sqlx::query!(
"SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL",
did
)
.fetch_all(&state.db)
.await;
let backup_codes = match backup_codes {
Ok(codes) => codes,
Err(e) => {
warn!("Failed to fetch backup codes: {:?}", e);
return false;
}
};
for row in backup_codes {
if verify_backup_code(&code, &row.code_hash) {
let _ = sqlx::query!(
"UPDATE backup_codes SET used_at = $1 WHERE id = $2",
Utc::now(),
row.id
)
.execute(&state.db)
.await;
return true;
}
}
false
}
pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
let code = code.trim();
if is_backup_code_format(code) {
return verify_backup_code_for_user(state, did, code).await;
}
let totp_row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await;
let totp_row = match totp_row {
Ok(Some(row)) if row.verified => row,
_ => return false,
};
let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
{
Ok(s) => s,
Err(_) => return false,
};
if verify_totp_code(&secret, code) {
let _ = sqlx::query!("UPDATE user_totp SET last_used = NOW() WHERE did = $1", did)
.execute(&state.db)
.await;
return true;
}
false
}
pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
.fetch_optional(&state.db)
.await;
matches!(result, Ok(Some(true)))
}
+2
View File
@@ -11,7 +11,9 @@ pub mod extractor;
pub mod scope_check;
pub mod service;
pub mod token;
pub mod totp;
pub mod verify;
pub mod webauthn;
pub use extractor::{
AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,
+194
View File
@@ -0,0 +1,194 @@
use base32::Alphabet;
use rand::RngCore;
use subtle::ConstantTimeEq;
use totp_rs::{Algorithm, TOTP};
const TOTP_DIGITS: usize = 6;
const TOTP_STEP: u64 = 30;
const TOTP_SECRET_LENGTH: usize = 20;
pub fn generate_totp_secret() -> Vec<u8> {
let mut secret = vec![0u8; TOTP_SECRET_LENGTH];
rand::thread_rng().fill_bytes(&mut secret);
secret
}
pub fn encrypt_totp_secret(secret: &[u8]) -> Result<Vec<u8>, String> {
crate::config::encrypt_key(secret)
}
pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result<Vec<u8>, String> {
crate::config::decrypt_key(encrypted, Some(version))
}
fn create_totp(
secret: Vec<u8>,
issuer: Option<String>,
account_name: String,
) -> Result<TOTP, String> {
TOTP::new(
Algorithm::SHA1,
TOTP_DIGITS,
1,
TOTP_STEP,
secret,
issuer,
account_name,
)
.map_err(|e| format!("Failed to create TOTP: {}", e))
}
pub fn verify_totp_code(secret: &[u8], code: &str) -> bool {
let code = code.trim();
if code.len() != TOTP_DIGITS {
return false;
}
let Ok(totp) = create_totp(secret.to_vec(), None, String::new()) else {
return false;
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for offset in [-1i64, 0, 1] {
let time = (now as i64 + offset * TOTP_STEP as i64) as u64;
let expected = totp.generate(time);
let is_valid: bool = code.as_bytes().ct_eq(expected.as_bytes()).into();
if is_valid {
return true;
}
}
false
}
pub fn generate_totp_uri(secret: &[u8], account_name: &str, issuer: &str) -> String {
let secret_base32 = base32::encode(Alphabet::Rfc4648 { padding: false }, secret);
format!(
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}",
urlencoding::encode(issuer),
urlencoding::encode(account_name),
secret_base32,
urlencoding::encode(issuer),
TOTP_DIGITS,
TOTP_STEP
)
}
pub fn generate_qr_png_base64(
secret: &[u8],
account_name: &str,
issuer: &str,
) -> Result<String, String> {
use base64::{Engine, engine::general_purpose::STANDARD};
let totp = create_totp(
secret.to_vec(),
Some(issuer.to_string()),
account_name.to_string(),
)?;
let qr_png = totp
.get_qr_png()
.map_err(|e| format!("Failed to generate QR code: {}", e))?;
Ok(STANDARD.encode(qr_png))
}
const BACKUP_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
const BACKUP_CODE_LENGTH: usize = 8;
const BACKUP_CODE_COUNT: usize = 10;
const BACKUP_CODE_BCRYPT_COST: u32 = 10;
pub fn generate_backup_codes() -> Vec<String> {
let mut codes = Vec::with_capacity(BACKUP_CODE_COUNT);
let mut rng = rand::thread_rng();
for _ in 0..BACKUP_CODE_COUNT {
let mut code = String::with_capacity(BACKUP_CODE_LENGTH);
for _ in 0..BACKUP_CODE_LENGTH {
let idx = (rng.next_u32() as usize) % BACKUP_CODE_ALPHABET.len();
code.push(BACKUP_CODE_ALPHABET[idx] as char);
}
codes.push(code);
}
codes
}
pub fn hash_backup_code(code: &str) -> Result<String, String> {
bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| format!("Failed to hash code: {}", e))
}
pub fn verify_backup_code(code: &str, hash: &str) -> bool {
bcrypt::verify(code, hash).unwrap_or(false)
}
pub fn is_backup_code_format(code: &str) -> bool {
let code = code.trim().to_uppercase();
code.len() == BACKUP_CODE_LENGTH
&& code
.chars()
.all(|c| BACKUP_CODE_ALPHABET.contains(&(c as u8)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_totp_secret() {
let secret = generate_totp_secret();
assert_eq!(secret.len(), TOTP_SECRET_LENGTH);
}
#[test]
fn test_verify_totp_code() {
let secret = generate_totp_secret();
let totp = create_totp(secret.clone(), None, String::new()).unwrap();
let code = totp.generate_current().unwrap();
assert!(verify_totp_code(&secret, &code));
assert!(!verify_totp_code(&secret, "000000"));
}
#[test]
fn test_generate_totp_uri() {
let secret = vec![0u8; 20];
let uri = generate_totp_uri(&secret, "test@example.com", "TestPDS");
assert!(uri.starts_with("otpauth://totp/"));
assert!(uri.contains("secret="));
assert!(uri.contains("issuer=TestPDS"));
}
#[test]
fn test_backup_codes() {
let codes = generate_backup_codes();
assert_eq!(codes.len(), BACKUP_CODE_COUNT);
for code in &codes {
assert_eq!(code.len(), BACKUP_CODE_LENGTH);
assert!(is_backup_code_format(code));
}
}
#[test]
fn test_backup_code_hash_verify() {
let codes = generate_backup_codes();
let code = &codes[0];
let hash = hash_backup_code(code).unwrap();
assert!(verify_backup_code(code, &hash));
assert!(!verify_backup_code("WRONGCOD", &hash));
}
#[test]
fn test_is_backup_code_format() {
assert!(is_backup_code_format("ABCD2345"));
assert!(is_backup_code_format(" abcd2345 "));
assert!(!is_backup_code_format("ABCD234"));
assert!(!is_backup_code_format("ABCD23456"));
assert!(!is_backup_code_format("ABCD234O"));
assert!(!is_backup_code_format("ABCD2341"));
}
}
+386
View File
@@ -0,0 +1,386 @@
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{Duration, Utc};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use webauthn_rs::prelude::*;
pub struct WebAuthnConfig {
webauthn: Webauthn,
}
impl WebAuthnConfig {
pub fn new(hostname: &str) -> Result<Self, String> {
let rp_id = hostname.to_string();
let rp_origin = Url::parse(&format!("https://{}", hostname))
.map_err(|e| format!("Invalid origin URL: {}", e))?;
let builder = WebauthnBuilder::new(&rp_id, &rp_origin)
.map_err(|e| format!("Failed to create WebAuthn builder: {}", e))?
.rp_name("Tranquil PDS")
.danger_set_user_presence_only_security_keys(true);
let webauthn = builder
.build()
.map_err(|e| format!("Failed to build WebAuthn: {}", e))?;
Ok(Self { webauthn })
}
pub fn start_registration(
&self,
user_id: &str,
username: &str,
display_name: &str,
exclude_credentials: Vec<CredentialID>,
) -> Result<(CreationChallengeResponse, SecurityKeyRegistration), String> {
let user_unique_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, user_id.as_bytes());
self.webauthn
.start_securitykey_registration(
user_unique_id,
username,
display_name,
if exclude_credentials.is_empty() {
None
} else {
Some(exclude_credentials)
},
None,
None,
)
.map_err(|e| format!("Failed to start registration: {}", e))
}
pub fn finish_registration(
&self,
reg: &RegisterPublicKeyCredential,
state: &SecurityKeyRegistration,
) -> Result<SecurityKey, String> {
self.webauthn
.finish_securitykey_registration(reg, state)
.map_err(|e| format!("Failed to finish registration: {}", e))
}
pub fn start_authentication(
&self,
credentials: Vec<SecurityKey>,
) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), String> {
self.webauthn
.start_securitykey_authentication(&credentials)
.map_err(|e| format!("Failed to start authentication: {}", e))
}
pub fn finish_authentication(
&self,
auth: &PublicKeyCredential,
state: &SecurityKeyAuthentication,
) -> Result<AuthenticationResult, String> {
self.webauthn
.finish_securitykey_authentication(auth, state)
.map_err(|e| format!("Failed to finish authentication: {}", e))
}
}
pub async fn save_registration_state(
pool: &PgPool,
did: &str,
state: &SecurityKeyRegistration,
) -> Result<Uuid, sqlx::Error> {
let id = Uuid::new_v4();
let state_json = serde_json::to_string(state)
.map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize state: {}", e)))?;
let challenge = id.as_bytes().to_vec();
let expires_at = Utc::now() + Duration::minutes(5);
sqlx::query!(
r#"
INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)
VALUES ($1, $2, $3, 'registration', $4, $5)
"#,
id,
did,
challenge,
state_json,
expires_at,
)
.execute(pool)
.await?;
Ok(id)
}
pub async fn load_registration_state(
pool: &PgPool,
did: &str,
) -> Result<Option<SecurityKeyRegistration>, sqlx::Error> {
let row = sqlx::query!(
r#"
SELECT state_json FROM webauthn_challenges
WHERE did = $1 AND challenge_type = 'registration' AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
"#,
did,
)
.fetch_optional(pool)
.await?;
match row {
Some(r) => {
let state: SecurityKeyRegistration =
serde_json::from_str(&r.state_json).map_err(|e| {
sqlx::Error::Protocol(format!("Failed to deserialize state: {}", e))
})?;
Ok(Some(state))
}
None => Ok(None),
}
}
pub async fn delete_registration_state(pool: &PgPool, did: &str) -> Result<(), sqlx::Error> {
sqlx::query!(
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'registration'",
did,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn save_authentication_state(
pool: &PgPool,
did: &str,
state: &SecurityKeyAuthentication,
) -> Result<Uuid, sqlx::Error> {
let id = Uuid::new_v4();
let state_json = serde_json::to_string(state)
.map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize state: {}", e)))?;
let challenge = id.as_bytes().to_vec();
let expires_at = Utc::now() + Duration::minutes(5);
sqlx::query!(
r#"
INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)
VALUES ($1, $2, $3, 'authentication', $4, $5)
"#,
id,
did,
challenge,
state_json,
expires_at,
)
.execute(pool)
.await?;
Ok(id)
}
pub async fn load_authentication_state(
pool: &PgPool,
did: &str,
) -> Result<Option<SecurityKeyAuthentication>, sqlx::Error> {
let row = sqlx::query!(
r#"
SELECT state_json FROM webauthn_challenges
WHERE did = $1 AND challenge_type = 'authentication' AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 1
"#,
did,
)
.fetch_optional(pool)
.await?;
match row {
Some(r) => {
let state: SecurityKeyAuthentication =
serde_json::from_str(&r.state_json).map_err(|e| {
sqlx::Error::Protocol(format!("Failed to deserialize state: {}", e))
})?;
Ok(Some(state))
}
None => Ok(None),
}
}
pub async fn delete_authentication_state(pool: &PgPool, did: &str) -> Result<(), sqlx::Error> {
sqlx::query!(
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'authentication'",
did,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn cleanup_expired_challenges(pool: &PgPool) -> Result<u64, sqlx::Error> {
let result = sqlx::query!("DELETE FROM webauthn_challenges WHERE expires_at < NOW()")
.execute(pool)
.await?;
Ok(result.rows_affected())
}
#[derive(Debug, Clone)]
pub struct StoredPasskey {
pub id: Uuid,
pub did: String,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub sign_count: i32,
pub created_at: chrono::DateTime<Utc>,
pub last_used: Option<chrono::DateTime<Utc>>,
pub friendly_name: Option<String>,
pub aaguid: Option<Vec<u8>>,
pub transports: Option<Vec<String>>,
}
impl StoredPasskey {
pub fn to_security_key(&self) -> Result<SecurityKey, String> {
serde_json::from_slice(&self.public_key)
.map_err(|e| format!("Failed to deserialize security key: {}", e))
}
pub fn credential_id_base64(&self) -> String {
URL_SAFE_NO_PAD.encode(&self.credential_id)
}
}
pub async fn save_passkey(
pool: &PgPool,
did: &str,
security_key: &SecurityKey,
friendly_name: Option<&str>,
) -> Result<Uuid, sqlx::Error> {
let id = Uuid::new_v4();
let credential_id = security_key.cred_id().to_vec();
let public_key = serde_json::to_vec(security_key)
.map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize security key: {}", e)))?;
let aaguid: Option<Vec<u8>> = None;
sqlx::query!(
r#"
INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name, aaguid)
VALUES ($1, $2, $3, $4, 0, $5, $6)
"#,
id,
did,
credential_id,
public_key,
friendly_name,
aaguid,
)
.execute(pool)
.await?;
Ok(id)
}
pub async fn get_passkeys_for_user(
pool: &PgPool,
did: &str,
) -> Result<Vec<StoredPasskey>, sqlx::Error> {
let rows = sqlx::query!(
r#"
SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports
FROM passkeys
WHERE did = $1
ORDER BY created_at DESC
"#,
did,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| StoredPasskey {
id: r.id,
did: r.did,
credential_id: r.credential_id,
public_key: r.public_key,
sign_count: r.sign_count,
created_at: r.created_at,
last_used: r.last_used,
friendly_name: r.friendly_name,
aaguid: r.aaguid,
transports: r.transports,
})
.collect())
}
pub async fn get_passkey_by_credential_id(
pool: &PgPool,
credential_id: &[u8],
) -> Result<Option<StoredPasskey>, sqlx::Error> {
let row = sqlx::query!(
r#"
SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports
FROM passkeys
WHERE credential_id = $1
"#,
credential_id,
)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| StoredPasskey {
id: r.id,
did: r.did,
credential_id: r.credential_id,
public_key: r.public_key,
sign_count: r.sign_count,
created_at: r.created_at,
last_used: r.last_used,
friendly_name: r.friendly_name,
aaguid: r.aaguid,
transports: r.transports,
}))
}
pub async fn update_passkey_counter(
pool: &PgPool,
credential_id: &[u8],
new_counter: u32,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2",
new_counter as i32,
credential_id,
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND did = $2")
.bind(id)
.bind(did)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_passkey_name(
pool: &PgPool,
id: Uuid,
did: &str,
name: &str,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query("UPDATE passkeys SET friendly_name = $1 WHERE id = $2 AND did = $3")
.bind(name)
.bind(id)
.bind(did)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn has_passkeys(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
let row = sqlx::query("SELECT COUNT(*) as count FROM passkeys WHERE did = $1")
.bind(did)
.fetch_one(pool)
.await?;
let count: i64 = row.get("count");
Ok(count > 0)
}
+56
View File
@@ -278,6 +278,46 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.atproto.server.getAccountInviteCodes",
get(api::server::get_account_invite_codes),
)
.route(
"/xrpc/com.atproto.server.createTotpSecret",
post(api::server::create_totp_secret),
)
.route(
"/xrpc/com.atproto.server.enableTotp",
post(api::server::enable_totp),
)
.route(
"/xrpc/com.atproto.server.disableTotp",
post(api::server::disable_totp),
)
.route(
"/xrpc/com.atproto.server.getTotpStatus",
get(api::server::get_totp_status),
)
.route(
"/xrpc/com.atproto.server.regenerateBackupCodes",
post(api::server::regenerate_backup_codes),
)
.route(
"/xrpc/com.atproto.server.startPasskeyRegistration",
post(api::server::start_passkey_registration),
)
.route(
"/xrpc/com.atproto.server.finishPasskeyRegistration",
post(api::server::finish_passkey_registration),
)
.route(
"/xrpc/com.atproto.server.listPasskeys",
get(api::server::list_passkeys),
)
.route(
"/xrpc/com.atproto.server.deletePasskey",
post(api::server::delete_passkey),
)
.route(
"/xrpc/com.atproto.server.updatePasskey",
post(api::server::update_passkey),
)
.route(
"/xrpc/com.atproto.admin.getInviteCodes",
get(api::admin::get_invite_codes),
@@ -359,6 +399,22 @@ pub fn app(state: AppState) -> Router {
"/oauth/authorize/2fa",
post(oauth::endpoints::authorize_2fa_post),
)
.route(
"/oauth/passkey/check",
get(oauth::endpoints::check_user_has_passkeys),
)
.route(
"/oauth/security-status",
get(oauth::endpoints::check_user_security_status),
)
.route(
"/oauth/passkey/start",
post(oauth::endpoints::passkey_start),
)
.route(
"/oauth/passkey/finish",
post(oauth::endpoints::passkey_finish),
)
.route(
"/oauth/authorize/deny",
post(oauth::endpoints::authorize_deny),
+773 -49
View File
@@ -486,6 +486,25 @@ pub async fn authorize_post(
if !password_valid {
return show_login_error("Invalid handle/email or password.", json_response);
}
let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await;
if has_totp {
if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
if json_response {
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
return redirect_see_other(&format!(
"/#/oauth/totp?request_uri={}",
url_encode(&form.request_uri)
));
}
if user.two_factor_enabled {
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
match db::create_2fa_challenge(&state.db, &user.did, &form.request_uri).await {
@@ -745,6 +764,23 @@ pub async fn authorize_select(
"Please verify your account before logging in.",
);
}
let has_totp = crate::api::server::has_totp_enabled(&state, &form.did).await;
if has_totp {
if db::set_authorization_did(&state.db, &form.request_uri, &form.did, Some(&device_id))
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
if user.two_factor_enabled {
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
match db::create_2fa_challenge(&state.db, &form.did, &form.request_uri).await {
@@ -1323,54 +1359,6 @@ pub async fn authorize_2fa_post(
"Too many attempts. Please try again later.",
);
}
let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await {
Ok(Some(c)) => c,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"No 2FA challenge found. Please start over.",
);
}
Err(_) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
};
if challenge.expires_at < Utc::now() {
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"2FA code has expired. Please start over.",
);
}
if challenge.attempts >= MAX_2FA_ATTEMPTS {
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
return json_error(
StatusCode::FORBIDDEN,
"access_denied",
"Too many failed attempts. Please start over.",
);
}
let code_valid: bool = form
.code
.trim()
.as_bytes()
.ct_eq(challenge.code.as_bytes())
.into();
if !code_valid {
let _ = db::increment_2fa_attempts(&state.db, challenge.id).await;
return json_error(
StatusCode::FORBIDDEN,
"invalid_code",
"Invalid verification code. Please try again.",
);
}
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(d)) => d,
Ok(None) => {
@@ -1388,12 +1376,135 @@ pub async fn authorize_2fa_post(
);
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request has expired.",
);
}
let challenge = db::get_2fa_challenge(&state.db, &form.request_uri)
.await
.ok()
.flatten();
if let Some(challenge) = challenge {
if challenge.expires_at < Utc::now() {
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"2FA code has expired. Please start over.",
);
}
if challenge.attempts >= MAX_2FA_ATTEMPTS {
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
return json_error(
StatusCode::FORBIDDEN,
"access_denied",
"Too many failed attempts. Please start over.",
);
}
let code_valid: bool = form
.code
.trim()
.as_bytes()
.ct_eq(challenge.code.as_bytes())
.into();
if !code_valid {
let _ = db::increment_2fa_attempts(&state.db, challenge.id).await;
return json_error(
StatusCode::FORBIDDEN,
"invalid_code",
"Invalid verification code. Please try again.",
);
}
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
let code = Code::generate();
let device_id = extract_device_cookie(&headers);
if db::update_authorization_request(
&state.db,
&form.request_uri,
&challenge.did,
device_id.as_deref(),
&code.0,
)
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.as_deref(),
);
return Json(serde_json::json!({
"redirect_uri": redirect_url
}))
.into_response();
}
let did = match &request_data.did {
Some(d) => d.clone(),
None => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"No 2FA challenge found. Please start over.",
);
}
};
if !crate::api::server::has_totp_enabled(&state, &did).await {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"No 2FA challenge found. Please start over.",
);
}
let totp_valid =
crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await;
if !totp_valid {
return json_error(
StatusCode::FORBIDDEN,
"invalid_code",
"Invalid verification code. Please try again.",
);
}
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<String> = requested_scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let needs_consent = db::should_show_consent(
&state.db,
&did,
&request_data.parameters.client_id,
&requested_scopes,
)
.await
.unwrap_or(true);
if needs_consent {
let consent_url = format!(
"/#/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
let device_id = extract_device_cookie(&headers);
if db::update_authorization_request(
&state.db,
&form.request_uri,
&challenge.did,
&did,
device_id.as_deref(),
&code.0,
)
@@ -1417,3 +1528,616 @@ pub async fn authorize_2fa_post(
}))
.into_response()
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckPasskeysQuery {
pub identifier: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckPasskeysResponse {
pub has_passkeys: bool,
}
pub async fn check_user_has_passkeys(
State(state): State<AppState>,
Query(query): Query<CheckPasskeysQuery>,
) -> Response {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_identifier = query.identifier.trim();
let normalized_identifier = normalized_identifier
.strip_prefix('@')
.unwrap_or(normalized_identifier);
let normalized_identifier = if let Some(bare_handle) =
normalized_identifier.strip_suffix(&format!(".{}", pds_hostname))
{
bare_handle.to_string()
} else {
normalized_identifier.to_string()
};
let user = sqlx::query!(
"SELECT did FROM users WHERE handle = $1 OR email = $1",
normalized_identifier
)
.fetch_optional(&state.db)
.await;
let has_passkeys = match user {
Ok(Some(u)) => crate::api::server::has_passkeys_for_user(&state, &u.did).await,
_ => false,
};
Json(CheckPasskeysResponse { has_passkeys }).into_response()
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SecurityStatusResponse {
pub has_passkeys: bool,
pub has_totp: bool,
}
pub async fn check_user_security_status(
State(state): State<AppState>,
Query(query): Query<CheckPasskeysQuery>,
) -> Response {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_identifier = query.identifier.trim();
let normalized_identifier = normalized_identifier
.strip_prefix('@')
.unwrap_or(normalized_identifier);
let normalized_identifier = if let Some(bare_handle) =
normalized_identifier.strip_suffix(&format!(".{}", pds_hostname))
{
bare_handle.to_string()
} else {
normalized_identifier.to_string()
};
let user = sqlx::query!(
"SELECT did FROM users WHERE handle = $1 OR email = $1",
normalized_identifier
)
.fetch_optional(&state.db)
.await;
let (has_passkeys, has_totp) = match user {
Ok(Some(u)) => {
let passkeys = crate::api::server::has_passkeys_for_user(&state, &u.did).await;
let totp = crate::api::server::has_totp_enabled(&state, &u.did).await;
(passkeys, totp)
}
_ => (false, false),
};
Json(SecurityStatusResponse {
has_passkeys,
has_totp,
})
.into_response()
}
#[derive(Debug, Deserialize)]
pub struct PasskeyStartInput {
pub request_uri: String,
pub identifier: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyStartResponse {
pub options: serde_json::Value,
}
pub async fn passkey_start(
State(state): State<AppState>,
headers: HeaderMap,
Json(form): Json<PasskeyStartInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "OAuth passkey rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "RateLimitExceeded",
"error_description": "Too many login attempts. Please try again later."
})),
)
.into_response();
}
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri."
})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired."
})),
)
.into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_username = form.identifier.trim();
let normalized_username = normalized_username
.strip_prefix('@')
.unwrap_or(normalized_username);
let normalized_username = if let Some(bare_handle) =
normalized_username.strip_suffix(&format!(".{}", pds_hostname))
{
bare_handle.to_string()
} else {
normalized_username.to_string()
};
let user = match sqlx::query!(
r#"
SELECT did, deactivated_at, takedown_ref,
email_verified, discord_verified, telegram_verified, signal_verified
FROM users
WHERE handle = $1 OR email = $1
"#,
normalized_username
)
.fetch_optional(&state.db)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "User not found or has no passkeys."
})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if user.deactivated_at.is_some() {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "This account has been deactivated."
})),
)
.into_response();
}
if user.takedown_ref.is_some() {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "This account has been taken down."
})),
)
.into_response();
}
let is_verified = user.email_verified
|| user.discord_verified
|| user.telegram_verified
|| user.signal_verified;
if !is_verified {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Please verify your account before logging in."
})),
)
.into_response();
}
let stored_passkeys =
match crate::auth::webauthn::get_passkeys_for_user(&state.db, &user.did).await {
Ok(pks) => pks,
Err(e) => {
tracing::error!(error = %e, "Failed to get passkeys");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if stored_passkeys.is_empty() {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "User not found or has no passkeys."
})),
)
.into_response();
}
let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys
.iter()
.filter_map(|sp| sp.to_security_key().ok())
.collect();
if passkeys.is_empty() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to load passkeys."
})),
)
.into_response();
}
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
tracing::error!(error = %e, "Failed to create WebAuthn config");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "WebAuthn configuration failed."
})),
)
.into_response();
}
};
let (rcr, auth_state) = match webauthn.start_authentication(passkeys) {
Ok(result) => result,
Err(e) => {
tracing::error!(error = %e, "Failed to start passkey authentication");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to start authentication."
})),
)
.into_response();
}
};
if let Err(e) =
crate::auth::webauthn::save_authentication_state(&state.db, &user.did, &auth_state).await
{
tracing::error!(error = %e, "Failed to save authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None)
.await
.is_err()
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
Json(PasskeyStartResponse { options }).into_response()
}
#[derive(Debug, Deserialize)]
pub struct PasskeyFinishInput {
pub request_uri: String,
pub credential: serde_json::Value,
}
pub async fn passkey_finish(
State(state): State<AppState>,
headers: HeaderMap,
Json(form): Json<PasskeyFinishInput>,
) -> Response {
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri."
})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired."
})),
)
.into_response();
}
let did = match request_data.did {
Some(d) => d,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress."
})),
)
.into_response();
}
};
let auth_state = match crate::auth::webauthn::load_authentication_state(&state.db, &did).await {
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "No passkey authentication in progress or challenge expired."
})),
)
.into_response();
}
Err(e) => {
tracing::error!(error = %e, "Failed to load authentication state");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
};
let credential: webauthn_rs::prelude::PublicKeyCredential =
match serde_json::from_value(form.credential) {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "Failed to parse credential");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Failed to parse credential response."
})),
)
.into_response();
}
};
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
tracing::error!(error = %e, "Failed to create WebAuthn config");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "WebAuthn configuration failed."
})),
)
.into_response();
}
};
let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication");
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Passkey verification failed."
})),
)
.into_response();
}
};
if let Err(e) = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await {
tracing::warn!(error = %e, "Failed to delete authentication state");
}
if auth_result.needs_update()
&& let Err(e) = crate::auth::webauthn::update_passkey_counter(
&state.db,
auth_result.cred_id(),
auth_result.counter(),
)
.await
{
tracing::warn!(error = %e, "Failed to update passkey counter");
}
tracing::info!(did = %did, "Passkey authentication successful");
let has_totp = crate::api::server::has_totp_enabled(&state, &did).await;
if has_totp {
return Json(serde_json::json!({
"needs_totp": true
}))
.into_response();
}
let user = sqlx::query!(
"SELECT two_factor_enabled, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", id FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await;
if let Ok(Some(user)) = user
&& user.two_factor_enabled
{
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
match db::create_2fa_challenge(&state.db, &did, &form.request_uri).await {
Ok(challenge) => {
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) =
enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await
{
tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification");
}
let channel_name = channel_display_name(user.preferred_comms_channel);
return Json(serde_json::json!({
"needs_2fa": true,
"channel": channel_name
}))
.into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
}
}
let device_id = extract_device_cookie(&headers);
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<String> = requested_scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let needs_consent = db::should_show_consent(
&state.db,
&did,
&request_data.parameters.client_id,
&requested_scopes,
)
.await
.unwrap_or(true);
if needs_consent {
let consent_url = format!(
"/#/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
if db::update_authorization_request(
&state.db,
&form.request_uri,
&did,
device_id.as_deref(),
&code.0,
)
.await
.is_err()
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "An error occurred."
})),
)
.into_response();
}
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.as_deref(),
);
Json(serde_json::json!({
"redirect_uri": redirect_url
}))
.into_response()
}