From fa6c4cc177193a2fd185c3b7a258c4435839680d Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 21 Dec 2025 23:16:39 +0200 Subject: [PATCH] Passkey-only accounts --- .config/nextest.toml | 13 + ...7ef762de989fb560247e2540f611e28362507.json | 15 + ...bbc6683442938b657e82da283bc07ceec4c80.json | 14 + ...dbb8827af340e3c04fec9a5c28aeff46e0c97.json | 2 +- ...580a02c92e579869f647cb08f65ac777854f5.json | 3 +- ...fdfa8a2e37aae3f171a2be73bee3057f86e08.json | 3 +- ...b3776851008324d64e0fdf04677105d0189d2.json | 22 + ...e92ffef44ef4b67ddc00327c6cd407b3436b8.json | 14 + ...4705adf34732730dab8346f814d8ef7599a74.json | 16 + ...2a1aeb1ea927d85e0d90392e25bfa440d364d.json | 15 + ...914ad3bf77d47dd393a0aae1c030b8ce98bcc.json | 3 +- ...e88b6949869e7d2cd3b40125597e29d7e0d20.json | 17 - ...61ac07a02ffa9a926f94508d7873c4ca07e65.json | 23 + ...2446a677d8ceb49ea00542f164dbb508f205f.json | 16 + ...200baef1f16427d87fb3afeb1e066c4084483.json | 30 + ...b2355892675be8cd589636d94f11d0f730bbc.json | 22 + ...c6f4c5a2a50155f6e8bdd14beef66dca97c1.json} | 19 +- ...966b4ac507646c442881d0a7aec58725622ed.json | 22 + ...b27a1dddea591429b4696dc68105b435b38f3.json | 23 + ...f80354bbe2f53f494e8e072ef92ef1418b034.json | 22 + ...55fe004e15020069a53019208deb2ba5be369.json | 16 + ...7e3f3ea071ffafbeeca821238bc062375953b.json | 40 + ...21c9e3eae1ce87c223b706ed81ebf973875f3.json | 28 + ...b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json | 14 + ...c3caae31316887a629866c6a90ddee373317.json} | 5 +- ...4bf34bd0b88971b0658d191ed57badbbfd979.json | 15 + ...3b4505d5442b12d00b9062ee0db5f58ae05b8.json | 41 + ...cccf1df977c95dba43937de548b56ccc8256a.json | 22 + ...f52074bfda921bbb300f23bdf1ccb096b5ea5.json | 17 - ...30004dc6b40b9495fa998caf6e4cdd26a43e4.json | 14 + ...89a2910508b9f7e6f60465dd4cb5c7a79d848.json | 40 + ...f55f4f811a0d666237a93035cfece07445590.json | 2 +- ...d58397bbf306c249bffd286457e471c00b745.json | 2 +- ...774681fb0fe9e88733fa4315e9aef799cd19f.json | 52 + ...1a7e3b62280bb697a81cbd39ff8a1207651a5.json | 15 + ...f3787606f9c74041deaceb7b011680f7b0a7.json} | 4 +- ...6c817a53bbf99998c5b1e428227d1b223b0f.json} | 24 +- ...1123f1c4e0cfa721449a3f4a495e6c3ce0289.json | 46 + ...f69e242cd9c16687fa9a72a618d57c8f0d9ba.json | 22 + ...635c06b4179b5858a7b388404c4b03fc92ab4.json | 3 +- ...a386c68f8539725e8558643889a4ef92067b4.json | 40 + frontend/src/App.svelte | 15 + frontend/src/components/ReauthModal.svelte | 430 ++++++ frontend/src/lib/api.ts | 144 +- frontend/src/routes/Login.svelte | 2 +- frontend/src/routes/OAuthConsent.svelte | 4 +- frontend/src/routes/OAuthLogin.svelte | 19 + frontend/src/routes/OAuthPasskey.svelte | 304 +++++ frontend/src/routes/OAuthTotp.svelte | 28 +- frontend/src/routes/RecoverPasskey.svelte | 266 ++++ frontend/src/routes/Register.svelte | 3 + frontend/src/routes/RegisterPasskey.svelte | 1011 ++++++++++++++ .../src/routes/RequestPasskeyRecovery.svelte | 205 +++ frontend/src/routes/ResetPassword.svelte | 14 +- frontend/src/routes/Security.svelte | 182 +++ frontend/src/routes/Settings.svelte | 2 +- frontend/src/routes/TrustedDevices.svelte | 409 ++++++ migrations/20251225_passwordless_accounts.sql | 5 + migrations/20251226_trusted_devices.sql | 4 + migrations/20251227_reauth_tracking.sql | 1 + ...251228_add_passkey_recovery_comms_type.sql | 1 + src/api/identity/account.rs | 65 +- src/api/identity/did.rs | 8 +- src/api/server/account_status.rs | 6 +- src/api/server/mod.rs | 27 +- src/api/server/passkey_account.rs | 1209 +++++++++++++++++ src/api/server/passkeys.rs | 8 +- src/api/server/password.rs | 131 +- src/api/server/reauth.rs | 482 +++++++ src/api/server/session.rs | 62 +- src/api/server/totp.rs | 16 +- src/api/server/trusted_devices.rs | 246 ++++ src/api/validation.rs | 101 +- src/comms/mod.rs | 4 +- src/comms/service.rs | 25 + src/comms/types.rs | 1 + src/lib.rs | 68 + src/oauth/endpoints/authorize.rs | 458 ++++++- tests/admin_search.rs | 55 +- tests/did_web.rs | 28 +- tests/identity.rs | 61 +- 81 files changed, 6683 insertions(+), 203 deletions(-) create mode 100644 .sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json create mode 100644 .sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json create mode 100644 .sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json create mode 100644 .sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json create mode 100644 .sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json create mode 100644 .sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json delete mode 100644 .sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json create mode 100644 .sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json create mode 100644 .sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json create mode 100644 .sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json create mode 100644 .sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json rename .sqlx/{query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json => query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json} (50%) create mode 100644 .sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json create mode 100644 .sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json create mode 100644 .sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json create mode 100644 .sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json create mode 100644 .sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json create mode 100644 .sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json create mode 100644 .sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json rename .sqlx/{query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json => query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json} (61%) create mode 100644 .sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json create mode 100644 .sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json create mode 100644 .sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json delete mode 100644 .sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json create mode 100644 .sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json create mode 100644 .sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json create mode 100644 .sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json create mode 100644 .sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json rename .sqlx/{query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json => query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json} (58%) rename .sqlx/{query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json => query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json} (75%) create mode 100644 .sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json create mode 100644 .sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json create mode 100644 .sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json create mode 100644 frontend/src/components/ReauthModal.svelte create mode 100644 frontend/src/routes/OAuthPasskey.svelte create mode 100644 frontend/src/routes/RecoverPasskey.svelte create mode 100644 frontend/src/routes/RegisterPasskey.svelte create mode 100644 frontend/src/routes/RequestPasskeyRecovery.svelte create mode 100644 frontend/src/routes/TrustedDevices.svelte create mode 100644 migrations/20251225_passwordless_accounts.sql create mode 100644 migrations/20251226_trusted_devices.sql create mode 100644 migrations/20251227_reauth_tracking.sql create mode 100644 migrations/20251228_add_passkey_recovery_comms_type.sql create mode 100644 src/api/server/passkey_account.rs create mode 100644 src/api/server/reauth.rs create mode 100644 src/api/server/trusted_devices.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index 2cfd181..6aa4e79 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,10 +1,23 @@ [store] dir = "target/nextest" + [profile.default] retries = 0 fail-fast = true test-threads = "num-cpus" + [profile.ci] retries = 2 fail-fast = false test-threads = "num-cpus" + +[test-groups] +serial-env-tests = { max-threads = 1 } + +[[profile.default.overrides]] +filter = "test(/import_with_verification/) | test(/plc_migration/)" +test-group = "serial-env-tests" + +[[profile.ci.overrides]] +filter = "test(/import_with_verification/) | test(/plc_migration/)" +test-group = "serial-env-tests" diff --git a/.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json b/.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json new file mode 100644 index 0000000..521625c --- /dev/null +++ b/.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507" +} diff --git a/.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json b/.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json new file mode 100644 index 0000000..0cac9aa --- /dev/null +++ b/.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80" +} diff --git a/.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json b/.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json index 8b18439..c210e37 100644 --- a/.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json +++ b/.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json @@ -26,7 +26,7 @@ }, "nullable": [ false, - false, + true, false ] }, diff --git a/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json index 4444b64..10b2ebf 100644 --- a/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json +++ b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json @@ -38,7 +38,8 @@ "admin_email", "plc_operation", "two_factor_code", - "channel_verification" + "channel_verification", + "passkey_recovery" ] } } diff --git a/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json index 7004e69..22f63d9 100644 --- a/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json +++ b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json @@ -46,7 +46,8 @@ "admin_email", "plc_operation", "two_factor_code", - "channel_verification" + "channel_verification", + "passkey_recovery" ] } } diff --git a/.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json b/.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json new file mode 100644 index 0000000..60cc9ec --- /dev/null +++ b/.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ap.password_hash FROM app_passwords ap\n JOIN users u ON ap.user_id = u.id\n WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2" +} diff --git a/.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json b/.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json new file mode 100644 index 0000000..a7770f3 --- /dev/null +++ b/.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8" +} diff --git a/.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json b/.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json new file mode 100644 index 0000000..a9ac379 --- /dev/null +++ b/.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74" +} diff --git a/.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json b/.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json new file mode 100644 index 0000000..04da0a9 --- /dev/null +++ b/.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d" +} diff --git a/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json index d87a8a1..b1ef31f 100644 --- a/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json +++ b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json @@ -38,7 +38,8 @@ "admin_email", "plc_operation", "two_factor_code", - "channel_verification" + "channel_verification", + "passkey_recovery" ] } } diff --git a/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json b/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json deleted file mode 100644 index 53ad312..0000000 --- a/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, 'email', $2, $3, $4)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20" -} diff --git a/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json b/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json new file mode 100644 index 0000000..6754294 --- /dev/null +++ b/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "trusted_until", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65" +} diff --git a/.sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json b/.sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json new file mode 100644 index 0000000..ba6da25 --- /dev/null +++ b/.sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f" +} diff --git a/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json b/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json new file mode 100644 index 0000000..e3a0c80 --- /dev/null +++ b/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + }, + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483" +} diff --git a/.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json b/.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json new file mode 100644 index 0000000..6cd55ff --- /dev/null +++ b/.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc" +} diff --git a/.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json b/.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json similarity index 50% rename from .sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json rename to .sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json index aaaa50b..f597186 100644 --- a/.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json +++ b/.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", "describe": { "columns": [ { @@ -16,7 +16,20 @@ ], "parameters": { "Left": [ - "Uuid" + "Uuid", + { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } ] }, "nullable": [ @@ -24,5 +37,5 @@ false ] }, - "hash": "126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32" + "hash": "5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1" } diff --git a/.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json b/.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json new file mode 100644 index 0000000..d1e2982 --- /dev/null +++ b/.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_password", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed" +} diff --git a/.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json b/.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json new file mode 100644 index 0000000..9258673 --- /dev/null +++ b/.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT 1 as one FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE oad.did = $1 AND od.id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "one", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3" +} diff --git a/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json b/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json new file mode 100644 index 0000000..3b32eaa --- /dev/null +++ b/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "one", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034" +} diff --git a/.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json b/.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json new file mode 100644 index 0000000..1fb61d4 --- /dev/null +++ b/.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369" +} diff --git a/.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json b/.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json new file mode 100644 index 0000000..bfaf2d4 --- /dev/null +++ b/.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "recovery_token", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "recovery_token_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "password_required", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + false + ] + }, + "hash": "73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b" +} diff --git a/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json b/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json new file mode 100644 index 0000000..bedeb59 --- /dev/null +++ b/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, password_hash FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3" +} diff --git a/.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json b/.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json new file mode 100644 index 0000000..b99e974 --- /dev/null +++ b/.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM passkeys WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180" +} diff --git a/.sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json b/.sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json similarity index 61% rename from .sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json rename to .sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json index 1cf5faf..e025c98 100644 --- a/.sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json +++ b/.sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id FROM users WHERE LOWER(email) = $1", + "query": "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2", "describe": { "columns": [ { @@ -11,6 +11,7 @@ ], "parameters": { "Left": [ + "Text", "Text" ] }, @@ -18,5 +19,5 @@ false ] }, - "hash": "d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4" + "hash": "7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317" } diff --git a/.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json b/.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json new file mode 100644 index 0000000..e775ef3 --- /dev/null +++ b/.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979" +} diff --git a/.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json b/.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json new file mode 100644 index 0000000..b1e7a0b --- /dev/null +++ b/.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_required", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8" +} diff --git a/.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json b/.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json new file mode 100644 index 0000000..93994b9 --- /dev/null +++ b/.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "last_reauth_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a" +} diff --git a/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json b/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json deleted file mode 100644 index 2ae0005..0000000 --- a/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5" -} diff --git a/.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json b/.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json new file mode 100644 index 0000000..c9788a4 --- /dev/null +++ b/.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4" +} diff --git a/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json b/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json new file mode 100644 index 0000000..b9a0999 --- /dev/null +++ b/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "recovery_token", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "recovery_token_expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848" +} diff --git a/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json b/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json index b290de5..a84c501 100644 --- a/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json +++ b/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json @@ -63,7 +63,7 @@ false, false, false, - false, + true, false, false, false, diff --git a/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json b/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json index ab84790..2e3fb27 100644 --- a/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json +++ b/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - false + true ] }, "hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745" diff --git a/.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json b/.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json new file mode 100644 index 0000000..03a3984 --- /dev/null +++ b/.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at\n FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()\n ORDER BY od.last_seen_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "friendly_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "trusted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "trusted_until", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "last_seen_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + false + ] + }, + "hash": "d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f" +} diff --git a/.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json b/.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json new file mode 100644 index 0000000..f2fa3a7 --- /dev/null +++ b/.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5" +} diff --git a/.sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json b/.sqlx/query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json similarity index 58% rename from .sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json rename to .sqlx/query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json index 1f3560c..58a4fdd 100644 --- a/.sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json +++ b/.sqlx/query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2", + "query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b" + "hash": "e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7" } diff --git a/.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json b/.sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json similarity index 75% rename from .sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json rename to .sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json index 5f96327..484db6b 100644 --- a/.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json +++ b/.sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ", + "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n 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": [ { @@ -25,11 +25,16 @@ }, { "ordinal": 4, - "name": "two_factor_enabled", + "name": "password_required", "type_info": "Bool" }, { "ordinal": 5, + "name": "two_factor_enabled", + "type_info": "Bool" + }, + { + "ordinal": 6, "name": "preferred_comms_channel: CommsChannel", "type_info": { "Custom": { @@ -46,32 +51,32 @@ } }, { - "ordinal": 6, + "ordinal": 7, "name": "deactivated_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "takedown_ref", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, "name": "email_verified", "type_info": "Bool" }, { - "ordinal": 9, + "ordinal": 10, "name": "discord_verified", "type_info": "Bool" }, { - "ordinal": 10, + "ordinal": 11, "name": "telegram_verified", "type_info": "Bool" }, { - "ordinal": 11, + "ordinal": 12, "name": "signal_verified", "type_info": "Bool" } @@ -85,6 +90,7 @@ false, false, true, + true, false, false, false, @@ -96,5 +102,5 @@ false ] }, - "hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0" + "hash": "eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f" } diff --git a/.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json b/.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json new file mode 100644 index 0000000..7dcbcbe --- /dev/null +++ b/.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "recovery_token", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "recovery_token_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "password_required", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true, + false + ] + }, + "hash": "f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289" +} diff --git a/.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json b/.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json new file mode 100644 index 0000000..617adf8 --- /dev/null +++ b/.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_pw", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba" +} diff --git a/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json index 9e183a2..19e35a1 100644 --- a/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json +++ b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json @@ -41,7 +41,8 @@ "admin_email", "plc_operation", "two_factor_code", - "channel_verification" + "channel_verification", + "passkey_recovery" ] } } diff --git a/.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json b/.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json new file mode 100644 index 0000000..96c6668 --- /dev/null +++ b/.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\" FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "preferred_comms_channel: CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4" +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index bd291e9..0586495 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -3,8 +3,11 @@ import { initAuth, getAuthState } from './lib/auth.svelte' import Login from './routes/Login.svelte' import Register from './routes/Register.svelte' + import RegisterPasskey from './routes/RegisterPasskey.svelte' import Verify from './routes/Verify.svelte' import ResetPassword from './routes/ResetPassword.svelte' + import RecoverPasskey from './routes/RecoverPasskey.svelte' + import RequestPasskeyRecovery from './routes/RequestPasskeyRecovery.svelte' import Dashboard from './routes/Dashboard.svelte' import AppPasswords from './routes/AppPasswords.svelte' import InviteCodes from './routes/InviteCodes.svelte' @@ -18,8 +21,10 @@ import OAuthAccounts from './routes/OAuthAccounts.svelte' import OAuth2FA from './routes/OAuth2FA.svelte' import OAuthTotp from './routes/OAuthTotp.svelte' + import OAuthPasskey from './routes/OAuthPasskey.svelte' import OAuthError from './routes/OAuthError.svelte' import Security from './routes/Security.svelte' + import TrustedDevices from './routes/TrustedDevices.svelte' const auth = getAuthState() @@ -33,10 +38,16 @@ return Login case '/register': return Register + case '/register-passkey': + return RegisterPasskey case '/verify': return Verify case '/reset-password': return ResetPassword + case '/recover-passkey': + return RecoverPasskey + case '/request-passkey-recovery': + return RequestPasskeyRecovery case '/dashboard': return Dashboard case '/app-passwords': @@ -63,10 +74,14 @@ return OAuth2FA case '/oauth/totp': return OAuthTotp + case '/oauth/passkey': + return OAuthPasskey case '/oauth/error': return OAuthError case '/security': return Security + case '/trusted-devices': + return TrustedDevices default: return auth.session ? Dashboard : Login } diff --git a/frontend/src/components/ReauthModal.svelte b/frontend/src/components/ReauthModal.svelte new file mode 100644 index 0000000..264a372 --- /dev/null +++ b/frontend/src/components/ReauthModal.svelte @@ -0,0 +1,430 @@ + + +{#if show} + +{/if} + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c918a1f..37f7363 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,10 +2,12 @@ const API_BASE = '/xrpc' export class ApiError extends Error { public did?: string - constructor(public status: number, public error: string, message: string, did?: string) { + public reauthMethods?: string[] + constructor(public status: number, public error: string, message: string, did?: string, reauthMethods?: string[]) { super(message) this.name = 'ApiError' this.did = did + this.reauthMethods = reauthMethods } } @@ -35,7 +37,7 @@ async function xrpc(method: string, options?: { }) if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) - throw new ApiError(res.status, err.error, err.message, err.did) + throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods) } return res.json() } @@ -208,10 +210,11 @@ export const api = { }) }, - async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> { + async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> { return xrpc('com.atproto.server.requestEmailUpdate', { method: 'POST', token, + body: { email }, }) }, @@ -317,6 +320,17 @@ export const api = { }) }, + async removePassword(token: string): Promise<{ success: boolean }> { + return xrpc('com.tranquil.account.removePassword', { + method: 'POST', + token, + }) + }, + + async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { + return xrpc('com.tranquil.account.getPasswordStatus', { token }) + }, + async listSessions(token: string): Promise<{ sessions: Array<{ id: string @@ -569,4 +583,128 @@ export const api = { body: { id, friendlyName }, }) }, + + async listTrustedDevices(token: string): Promise<{ + devices: Array<{ + id: string + userAgent: string | null + friendlyName: string | null + trustedAt: string | null + trustedUntil: string | null + lastSeenAt: string + }> + }> { + return xrpc('com.tranquil.account.listTrustedDevices', { token }) + }, + + async revokeTrustedDevice(token: string, deviceId: string): Promise<{ success: boolean }> { + return xrpc('com.tranquil.account.revokeTrustedDevice', { + method: 'POST', + token, + body: { deviceId }, + }) + }, + + async updateTrustedDevice(token: string, deviceId: string, friendlyName: string): Promise<{ success: boolean }> { + return xrpc('com.tranquil.account.updateTrustedDevice', { + method: 'POST', + token, + body: { deviceId, friendlyName }, + }) + }, + + async getReauthStatus(token: string): Promise<{ + requiresReauth: boolean + lastReauthAt: string | null + availableMethods: string[] + }> { + return xrpc('com.tranquil.account.getReauthStatus', { token }) + }, + + async reauthPassword(token: string, password: string): Promise<{ success: boolean; reauthAt: string }> { + return xrpc('com.tranquil.account.reauthPassword', { + method: 'POST', + token, + body: { password }, + }) + }, + + async reauthTotp(token: string, code: string): Promise<{ success: boolean; reauthAt: string }> { + return xrpc('com.tranquil.account.reauthTotp', { + method: 'POST', + token, + body: { code }, + }) + }, + + async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { + return xrpc('com.tranquil.account.reauthPasskeyStart', { + method: 'POST', + token, + }) + }, + + async reauthPasskeyFinish(token: string, credential: unknown): Promise<{ success: boolean; reauthAt: string }> { + return xrpc('com.tranquil.account.reauthPasskeyFinish', { + method: 'POST', + token, + body: { credential }, + }) + }, + + async createPasskeyAccount(params: { + handle: string + email?: string + inviteCode?: string + didType?: DidType + did?: string + signingKey?: string + verificationChannel?: VerificationChannel + discordId?: string + telegramUsername?: string + signalNumber?: string + }): Promise<{ + did: string + handle: string + setupToken: string + setupExpiresAt: string + }> { + return xrpc('com.tranquil.account.createPasskeyAccount', { + method: 'POST', + body: params, + }) + }, + + async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> { + return xrpc('com.tranquil.account.startPasskeyRegistrationForSetup', { + method: 'POST', + body: { did, setupToken, friendlyName }, + }) + }, + + async completePasskeySetup(did: string, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string): Promise<{ + did: string + handle: string + appPassword: string + appPasswordName: string + }> { + return xrpc('com.tranquil.account.completePasskeySetup', { + method: 'POST', + body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, + }) + }, + + async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { + return xrpc('com.tranquil.account.requestPasskeyRecovery', { + method: 'POST', + body: { email }, + }) + }, + + async recoverPasskeyAccount(did: string, recoveryToken: string, newPassword: string): Promise<{ success: boolean }> { + return xrpc('com.tranquil.account.recoverPasskeyAccount', { + method: 'POST', + body: { did, recoveryToken, newPassword }, + }) + }, } diff --git a/frontend/src/routes/Login.svelte b/frontend/src/routes/Login.svelte index 127359e..d79e524 100644 --- a/frontend/src/routes/Login.svelte +++ b/frontend/src/routes/Login.svelte @@ -142,7 +142,7 @@ {submitting ? 'Redirecting...' : 'Sign In'} diff --git a/frontend/src/routes/OAuthTotp.svelte b/frontend/src/routes/OAuthTotp.svelte index dc5935a..14d8730 100644 --- a/frontend/src/routes/OAuthTotp.svelte +++ b/frontend/src/routes/OAuthTotp.svelte @@ -2,6 +2,7 @@ import { navigate } from '../lib/router.svelte' let code = $state('') + let trustDevice = $state(false) let submitting = $state(false) let error = $state(null) @@ -30,7 +31,8 @@ }, body: JSON.stringify({ request_uri: requestUri, - code: code.trim().toUpperCase() + code: code.trim().toUpperCase(), + trust_device: trustDevice }) }) @@ -104,6 +106,15 @@

+ +
+ {:else if success} +
+
+

Password Set!

+

+ Your temporary password has been set. You can now sign in with this password. +

+

+ After signing in, we recommend adding a new passkey in your security settings + to restore passkey-only authentication. +

+ +
+ {:else} +

Recover Your Account

+

+ Set a temporary password to regain access to your passkey-only account. +

+ + {#if error} +
{error}
+ {/if} + +
+
+ + +
+ +
+ + +
+ +
+ What happens next? +

+ After setting this password, you can sign in and add a new passkey in your security settings. + Once you have a new passkey, you can optionally remove the temporary password. +

+
+ + +
+ {/if} +
+ + diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index f6bbb9f..78b7802 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -342,6 +342,9 @@ + {/if} diff --git a/frontend/src/routes/RequestPasskeyRecovery.svelte b/frontend/src/routes/RequestPasskeyRecovery.svelte new file mode 100644 index 0000000..87ad3d0 --- /dev/null +++ b/frontend/src/routes/RequestPasskeyRecovery.svelte @@ -0,0 +1,205 @@ + + +
+ {#if success} +
+

Recovery Link Sent

+

+ If your account exists and is a passkey-only account, you'll receive a recovery link + at your preferred notification channel. +

+

+ The link will expire in 1 hour. Check your email, Discord, Telegram, or Signal + depending on your account settings. +

+ +
+ {:else} +

Recover Passkey Account

+

+ Lost access to your passkey? Enter your handle or email and we'll send you a recovery link. +

+ + {#if error} +
{error}
+ {/if} + +
+
+ + +
+ +
+ How it works +

+ We'll send a secure link to your registered notification channel. + Click the link to set a temporary password. Then you can sign in + and add a new passkey. +

+
+ + +
+ {/if} + + +
+ + diff --git a/frontend/src/routes/ResetPassword.svelte b/frontend/src/routes/ResetPassword.svelte index 95e972e..05394b8 100644 --- a/frontend/src/routes/ResetPassword.svelte +++ b/frontend/src/routes/ResetPassword.svelte @@ -25,7 +25,7 @@ try { await api.requestPasswordReset(email) tokenSent = true - success = 'Password reset code sent to your email' + success = 'Password reset code sent! Check your preferred notification channel.' } catch (e) { error = e instanceof ApiError ? e.message : 'Failed to send reset code' } finally { @@ -66,7 +66,7 @@ {/if} {#if tokenSent}

Reset Password

-

Enter the code from your email and choose a new password.

+

Enter the code you received and choose a new password.

@@ -74,7 +74,7 @@ id="token" type="text" bind:value={token} - placeholder="Enter code from email" + placeholder="Enter reset code" disabled={submitting} required /> @@ -111,15 +111,15 @@ {:else}

Forgot Password

-

Enter your email address and we'll send you a code to reset your password.

+

Enter your handle or email and we'll send you a code to reset your password.

- + diff --git a/frontend/src/routes/Security.svelte b/frontend/src/routes/Security.svelte index ac91a24..bb286df 100644 --- a/frontend/src/routes/Security.svelte +++ b/frontend/src/routes/Security.svelte @@ -2,6 +2,7 @@ import { getAuthState } from '../lib/auth.svelte' import { navigate } from '../lib/router.svelte' import { api, ApiError } from '../lib/api' + import ReauthModal from '../components/ReauthModal.svelte' const auth = getAuthState() let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) @@ -38,6 +39,15 @@ let editingPasskeyId = $state(null) let editPasskeyName = $state('') + let hasPassword = $state(true) + let passwordLoading = $state(true) + let showRemovePasswordForm = $state(false) + let removePasswordLoading = $state(false) + + let showReauthModal = $state(false) + let reauthMethods = $state(['password']) + let pendingAction = $state<(() => Promise) | null>(null) + $effect(() => { if (!auth.loading && !auth.session) { navigate('/login') @@ -48,9 +58,59 @@ if (auth.session) { loadTotpStatus() loadPasskeys() + loadPasswordStatus() } }) + async function loadPasswordStatus() { + if (!auth.session) return + passwordLoading = true + try { + const status = await api.getPasswordStatus(auth.session.accessJwt) + hasPassword = status.hasPassword + } catch { + hasPassword = true + } finally { + passwordLoading = false + } + } + + async function handleRemovePassword() { + if (!auth.session) return + removePasswordLoading = true + try { + await api.removePassword(auth.session.accessJwt) + hasPassword = false + showRemovePasswordForm = false + showMessage('success', 'Password removed. Your account is now passkey-only.') + } catch (e) { + if (e instanceof ApiError) { + if (e.error === 'ReauthRequired') { + reauthMethods = e.reauthMethods || ['password'] + pendingAction = handleRemovePassword + showReauthModal = true + } else { + showMessage('error', e.message) + } + } else { + showMessage('error', 'Failed to remove password') + } + } finally { + removePasswordLoading = false + } + } + + function handleReauthSuccess() { + if (pendingAction) { + pendingAction() + pendingAction = null + } + } + + function handleReauthCancel() { + pendingAction = null + } + async function loadTotpStatus() { if (!auth.session) return loading = true @@ -543,9 +603,83 @@
{/if} + +
+

Password

+

+ Manage your account password. If you have passkeys set up, you can optionally remove your password for a fully passwordless experience. +

+ + {#if passwordLoading} +
Loading...
+ {:else if hasPassword} +
+ Password authentication is enabled +
+ + {#if passkeys.length > 0} + {#if !showRemovePasswordForm} + + {:else} +
+

Remove Password

+

+ This will make your account passkey-only. You'll only be able to sign in using your registered passkeys. + If you lose access to all your passkeys, you can recover your account using your notification channel. +

+
+ Before proceeding: +
    +
  • Make sure you have at least one reliable passkey registered
  • +
  • Consider registering passkeys on multiple devices
  • +
  • Ensure your recovery notification channel is up to date
  • +
+
+
+ + +
+
+ {/if} + {:else} +

Add at least one passkey before you can remove your password.

+ {/if} + {:else} +
+ Your account is passkey-only +
+

+ You sign in using passkeys only. If you ever lose access to your passkeys, + you can recover your account using the "Lost passkey?" link on the login page. +

+ {/if} +
+ +
+

Trusted Devices

+

+ Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device. +

+ + Manage Trusted Devices → + +
{/if}
+ + diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte index 2631c7c..6a8ce8d 100644 --- a/frontend/src/routes/Settings.svelte +++ b/frontend/src/routes/Settings.svelte @@ -37,7 +37,7 @@ emailLoading = true message = null try { - const result = await api.requestEmailUpdate(auth.session.accessJwt) + const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail) emailTokenRequired = result.tokenRequired if (emailTokenRequired) { showMessage('success', 'Verification code sent to your current email') diff --git a/frontend/src/routes/TrustedDevices.svelte b/frontend/src/routes/TrustedDevices.svelte new file mode 100644 index 0000000..8a213a9 --- /dev/null +++ b/frontend/src/routes/TrustedDevices.svelte @@ -0,0 +1,409 @@ + + +
+
+ ← Security Settings +

Trusted Devices

+
+ + {#if message} +
{message.text}
+ {/if} + +
+

+ Trusted devices can skip two-factor authentication when logging in. + Trust is granted for 30 days and automatically extends when you use the device. +

+
+ + {#if loading} +
Loading...
+ {:else if devices.length === 0} +
+

No trusted devices yet.

+

When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.

+
+ {:else} +
+ {#each devices as device} +
+
+ {#if editingDeviceId === device.id} + +
+ + +
+ {:else} +

{device.friendlyName || parseUserAgent(device.userAgent)}

+ + {/if} +
+ +
+ {#if device.userAgent && !device.friendlyName} +

Browser: {device.userAgent}

+ {:else if device.userAgent} +

Browser: {parseUserAgent(device.userAgent)}

+ {/if} +

+ Last seen: {formatDate(device.lastSeenAt)} +

+ {#if device.trustedAt} +

+ Trusted since: {formatDate(device.trustedAt)} +

+ {/if} + {#if device.trustedUntil} + {@const daysRemaining = getDaysRemaining(device.trustedUntil)} +

+ Trust expires: + {#if daysRemaining <= 0} + Expired + {:else if daysRemaining === 1} + Tomorrow + {:else} + In {daysRemaining} days + {/if} +

+ {/if} +
+ +
+ +
+
+ {/each} +
+ {/if} +
+ + diff --git a/migrations/20251225_passwordless_accounts.sql b/migrations/20251225_passwordless_accounts.sql new file mode 100644 index 0000000..804cfec --- /dev/null +++ b/migrations/20251225_passwordless_accounts.sql @@ -0,0 +1,5 @@ +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; +ALTER TABLE users ADD COLUMN password_required BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE users ADD COLUMN recovery_token TEXT; +ALTER TABLE users ADD COLUMN recovery_token_expires_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_users_recovery_token ON users(recovery_token) WHERE recovery_token IS NOT NULL; diff --git a/migrations/20251226_trusted_devices.sql b/migrations/20251226_trusted_devices.sql new file mode 100644 index 0000000..1fbdde3 --- /dev/null +++ b/migrations/20251226_trusted_devices.sql @@ -0,0 +1,4 @@ +ALTER TABLE oauth_device ADD COLUMN trusted_at TIMESTAMPTZ; +ALTER TABLE oauth_device ADD COLUMN trusted_until TIMESTAMPTZ; +ALTER TABLE oauth_device ADD COLUMN friendly_name TEXT; +CREATE INDEX IF NOT EXISTS idx_oauth_device_trusted ON oauth_device(trusted_until) WHERE trusted_until IS NOT NULL; diff --git a/migrations/20251227_reauth_tracking.sql b/migrations/20251227_reauth_tracking.sql new file mode 100644 index 0000000..06ae1ef --- /dev/null +++ b/migrations/20251227_reauth_tracking.sql @@ -0,0 +1 @@ +ALTER TABLE session_tokens ADD COLUMN last_reauth_at TIMESTAMPTZ; diff --git a/migrations/20251228_add_passkey_recovery_comms_type.sql b/migrations/20251228_add_passkey_recovery_comms_type.sql new file mode 100644 index 0000000..85eb698 --- /dev/null +++ b/migrations/20251228_add_passkey_recovery_comms_type.sql @@ -0,0 +1 @@ +ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'passkey_recovery'; diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index e89aa3c..7b4eebf 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -139,12 +139,18 @@ pub async fn create_account( info!(did = %migration_did, "Processing account migration"); } - let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname_for_validation = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_suffix = format!(".{}", hostname_for_validation); - let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { + let validated_short_handle = if !input.handle.contains('.') + || input.handle.ends_with(&pds_suffix) + { let handle_to_validate = if input.handle.ends_with(&pds_suffix) { - input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle) + input + .handle + .strip_suffix(&pds_suffix) + .unwrap_or(&input.handle) } else { &input.handle }; @@ -166,6 +172,15 @@ pub async fn create_account( ) .into_response(); } + for c in input.handle.chars() { + if !c.is_ascii_alphanumeric() && c != '.' && c != '-' { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidHandle", "message": format!("Handle contains invalid character: {}", c)})), + ) + .into_response(); + } + } input.handle.to_lowercase() }; let email: Option = input @@ -319,7 +334,9 @@ pub async fn create_account( ) .into_response(); } - if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { + if let Err(e) = + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await + { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidDid", "message": e})), @@ -335,7 +352,10 @@ pub async fn create_account( info!(did = %d, "Migration with existing did:plc"); d.clone() } else if d.starts_with("did:web:") { - if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { + if let Err(e) = + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) + .await + { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidDid", "message": e})), @@ -758,22 +778,24 @@ pub async fn create_account( }; if !is_migration - && let Err(e) = sqlx::query!( - "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)", - user_id, - verification_code, - email, - code_expires_at - ) - .execute(&mut *tx) - .await { - error!("Error inserting verification code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + && let Some(ref recipient) = verification_recipient + && let Err(e) = sqlx::query!( + "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)", + user_id, + verification_channel as _, + verification_code, + recipient, + code_expires_at ) - .into_response(); - } + .execute(&mut *tx) + .await { + error!("Error inserting verification code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { Ok(enc) => enc, Err(e) => { @@ -919,8 +941,7 @@ pub async fn create_account( } if !is_migration { if let Err(e) = - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) - .await + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await { warn!("Failed to sequence identity event for {}: {}", did, e); } diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index 29848e6..d83d23d 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -674,13 +674,9 @@ pub async fn update_handle( if let Some(old) = old_handle { let _ = state.cache.delete(&format!("handle:{}", old)).await; } - let _ = state - .cache - .delete(&format!("handle:{}", handle)) - .await; + let _ = state.cache.delete(&format!("handle:{}", handle)).await; if let Err(e) = - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) - .await + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await { warn!("Failed to sequence identity event for handle update: {}", e); } diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index 39b92bd..1216a8c 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -419,7 +419,11 @@ pub async fn delete_account( .into_response(); } }; - let password_valid = if verify(password, &password_hash).unwrap_or(false) { + let password_valid = if password_hash + .as_ref() + .map(|h| verify(password, h).unwrap_or(false)) + .unwrap_or(false) + { true } else { let app_pass_rows = sqlx::query!( diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 1b97506..043fbf4 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -3,12 +3,15 @@ pub mod app_password; pub mod email; pub mod invite; pub mod meta; +pub mod passkey_account; pub mod passkeys; pub mod password; +pub mod reauth; pub mod service_auth; pub mod session; pub mod signing_key; pub mod totp; +pub mod trusted_devices; pub use account_status::{ activate_account, check_account_status, deactivate_account, delete_account, @@ -18,11 +21,21 @@ 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 passkey_account::{ + complete_passkey_setup, create_passkey_account, recover_passkey_account, + request_passkey_recovery, start_passkey_registration_for_setup, +}; +pub use passkeys::{ + delete_passkey, finish_passkey_registration, has_passkeys_for_user, has_passkeys_for_user_db, + list_passkeys, start_passkey_registration, update_passkey, +}; +pub use password::{ + change_password, get_password_status, remove_password, request_password_reset, reset_password, +}; +pub use reauth::{ + check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start, + reauth_password, reauth_required_response, reauth_totp, }; -pub use password::{change_password, request_password_reset, reset_password}; pub use service_auth::get_service_auth; pub use session::{ confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session, @@ -31,5 +44,9 @@ pub use 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, + has_totp_enabled_db, regenerate_backup_codes, verify_totp_or_backup_for_user, +}; +pub use trusted_devices::{ + extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device, + trust_device, update_trusted_device, }; diff --git a/src/api/server/passkey_account.rs b/src/api/server/passkey_account.rs new file mode 100644 index 0000000..e7fe88e --- /dev/null +++ b/src/api/server/passkey_account.rs @@ -0,0 +1,1209 @@ +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use bcrypt::{DEFAULT_COST, hash}; +use chrono::{Duration, Utc}; +use jacquard::types::{did::Did, integer::LimitedU32, string::Tid}; +use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::sync::Arc; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use crate::state::{AppState, RateLimitKind}; + +fn extract_client_ip(headers: &HeaderMap) -> String { + if let Some(forwarded) = headers.get("x-forwarded-for") + && let Ok(value) = forwarded.to_str() + && let Some(first_ip) = value.split(',').next() + { + return first_ip.trim().to_string(); + } + if let Some(real_ip) = headers.get("x-real-ip") + && let Ok(value) = real_ip.to_str() + { + return value.trim().to_string(); + } + "unknown".to_string() +} + +fn generate_setup_token() -> String { + let mut rng = rand::thread_rng(); + (0..32) + .map(|_| { + let idx = rng.gen_range(0..36); + if idx < 10 { + (b'0' + idx) as char + } else { + (b'a' + idx - 10) as char + } + }) + .collect() +} + +fn generate_app_password() -> String { + let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut rng = rand::thread_rng(); + let segments: Vec = (0..4) + .map(|_| { + (0..4) + .map(|_| chars[rng.gen_range(0..chars.len())] as char) + .collect() + }) + .collect(); + segments.join("-") +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePasskeyAccountInput { + pub handle: String, + pub email: Option, + pub invite_code: Option, + pub did: Option, + pub did_type: Option, + pub signing_key: Option, + pub verification_channel: Option, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePasskeyAccountResponse { + pub did: String, + pub handle: String, + pub setup_token: String, + pub setup_expires_at: chrono::DateTime, +} + +pub async fn create_passkey_account( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let client_ip = extract_client_ip(&headers); + if !state + .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) + .await + { + warn!(ip = %client_ip, "Account creation rate limit exceeded"); + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({ + "error": "RateLimitExceeded", + "message": "Too many account creation attempts. Please try again later." + })), + ) + .into_response(); + } + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let pds_suffix = format!(".{}", hostname); + + let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { + let handle_to_validate = if input.handle.ends_with(&pds_suffix) { + input + .handle + .strip_suffix(&pds_suffix) + .unwrap_or(&input.handle) + } else { + &input.handle + }; + match crate::api::validation::validate_short_handle(handle_to_validate) { + Ok(h) => format!("{}.{}", h, hostname), + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidHandle", "message": e.to_string()})), + ) + .into_response(); + } + } + } else { + input.handle.to_lowercase() + }; + + let email = input + .email + .as_ref() + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()); + if let Some(ref email) = email + && !crate::api::validation::is_valid_email(email) + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), + ) + .into_response(); + } + + if let Some(ref code) = input.invite_code { + let valid = sqlx::query_scalar!( + "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1", + code + ) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .unwrap_or(Some(false)); + + if valid != Some(true) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})), + ) + .into_response(); + } + } else { + let invite_required = std::env::var("INVITE_CODE_REQUIRED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + if invite_required { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})), + ) + .into_response(); + } + } + + let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); + let verification_recipient = match verification_channel { + "email" => match &email { + Some(e) if !e.is_empty() => e.clone(), + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})), + ).into_response(), + }, + "discord" => match &input.discord_id { + Some(id) if !id.trim().is_empty() => id.trim().to_string(), + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})), + ).into_response(), + }, + "telegram" => match &input.telegram_username { + Some(username) if !username.trim().is_empty() => username.trim().to_string(), + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})), + ).into_response(), + }, + "signal" => match &input.signal_number { + Some(number) if !number.trim().is_empty() => number.trim().to_string(), + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})), + ).into_response(), + }, + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})), + ).into_response(), + }; + + use k256::ecdsa::SigningKey; + use rand::rngs::OsRng; + + let pds_endpoint = format!("https://{}", hostname); + let did_type = input.did_type.as_deref().unwrap_or("plc"); + + let (secret_key_bytes, reserved_key_id): (Vec, Option) = + if let Some(signing_key_did) = &input.signing_key { + let reserved = sqlx::query!( + r#" + SELECT id, private_key_bytes + FROM reserved_signing_keys + WHERE public_key_did_key = $1 + AND used_at IS NULL + AND expires_at > NOW() + FOR UPDATE + "#, + signing_key_did + ) + .fetch_optional(&state.db) + .await; + match reserved { + Ok(Some(row)) => (row.private_key_bytes, Some(row.id)), + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidSigningKey", + "message": "Signing key not found, already used, or expired" + })), + ) + .into_response(); + } + Err(e) => { + error!("Error looking up reserved signing key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } else { + let secret_key = k256::SecretKey::random(&mut OsRng); + (secret_key.to_bytes().to_vec(), None) + }; + + let secret_key = match SigningKey::from_slice(&secret_key_bytes) { + Ok(k) => k, + Err(e) => { + error!("Error creating signing key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let did = match did_type { + "web" => { + let subdomain_host = format!("{}.{}", input.handle, hostname); + let encoded_subdomain = subdomain_host.replace(':', "%3A"); + let self_hosted_did = format!("did:web:{}", encoded_subdomain); + info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account"); + self_hosted_did + } + "web-external" => { + let d = match &input.did { + Some(d) if !d.trim().is_empty() => d.trim(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})), + ) + .into_response(); + } + }; + if !d.starts_with("did:web:") { + return ( + StatusCode::BAD_REQUEST, + Json( + json!({"error": "InvalidDid", "message": "External DID must be a did:web"}), + ), + ) + .into_response(); + } + if let Err(e) = crate::api::identity::did::verify_did_web( + d, + &hostname, + &input.handle, + input.signing_key.as_deref(), + ) + .await + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidDid", "message": e})), + ) + .into_response(); + } + info!(did = %d, "Creating external did:web passkey account"); + d.to_string() + } + _ => { + let rotation_key = std::env::var("PLC_ROTATION_KEY") + .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key)); + + let genesis_result = match crate::plc::create_genesis_operation( + &secret_key, + &rotation_key, + &handle, + &pds_endpoint, + ) { + Ok(r) => r, + Err(e) => { + error!("Error creating PLC genesis operation: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), + ) + .into_response(); + } + }; + + let plc_client = crate::plc::PlcClient::new(None); + if let Err(e) = plc_client + .send_operation(&genesis_result.did, &genesis_result.signed_operation) + .await + { + error!("Failed to submit PLC genesis operation: {:?}", e); + return ( + StatusCode::BAD_GATEWAY, + Json(json!({ + "error": "UpstreamError", + "message": format!("Failed to register DID with PLC directory: {}", e) + })), + ) + .into_response(); + } + genesis_result.did + } + }; + + info!(did = %did, handle = %handle, "Created DID for passkey-only account"); + + let verification_code = format!( + "{:06}", + rand::Rng::gen_range(&mut rand::thread_rng(), 0..1_000_000u32) + ); + let verification_code_expires_at = Utc::now() + Duration::minutes(30); + + let setup_token = generate_setup_token(); + let setup_token_hash = match hash(&setup_token, DEFAULT_COST) { + Ok(h) => h, + Err(e) => { + error!("Error hashing setup token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let setup_expires_at = Utc::now() + Duration::hours(1); + + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Error starting transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let user_insert: Result<(Uuid,), _> = sqlx::query_as( + r#"INSERT INTO users ( + handle, email, did, password_hash, password_required, + preferred_comms_channel, + discord_id, telegram_username, signal_number, + recovery_token, recovery_token_expires_at + ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9) RETURNING id"#, + ) + .bind(&handle) + .bind(&email) + .bind(&did) + .bind(verification_channel) + .bind( + input + .discord_id + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()), + ) + .bind( + input + .telegram_username + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()), + ) + .bind( + input + .signal_number + .as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()), + ) + .bind(&setup_token_hash) + .bind(setup_expires_at) + .fetch_one(&mut *tx) + .await; + + let user_id = match user_insert { + Ok((id,)) => id, + Err(e) => { + if let Some(db_err) = e.as_database_error() + && db_err.code().as_deref() == Some("23505") + { + let constraint = db_err.constraint().unwrap_or(""); + if constraint.contains("handle") { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})), + ) + .into_response(); + } else if constraint.contains("email") { + return ( + StatusCode::BAD_REQUEST, + Json( + json!({"error": "InvalidEmail", "message": "Email already registered"}), + ), + ) + .into_response(); + } + } + error!("Error inserting user: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { + Ok(bytes) => bytes, + Err(e) => { + error!("Error encrypting signing key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", + user_id, + &encrypted_key_bytes[..], + crate::config::ENCRYPTION_VERSION + ) + .execute(&mut *tx) + .await + { + error!("Error inserting user key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Some(key_id) = reserved_key_id + && let Err(e) = sqlx::query!( + "UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1", + key_id + ) + .execute(&mut *tx) + .await + { + error!("Error marking reserved key as used: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + let mst = Mst::new(Arc::new(state.block_store.clone())); + let mst_root = match mst.persist().await { + Ok(c) => c, + Err(e) => { + error!("Error persisting MST: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let did_obj = match Did::new(&did) { + Ok(d) => d, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Invalid DID"})), + ) + .into_response(); + } + }; + let rev = Tid::now(LimitedU32::MIN); + let unsigned_commit = Commit::new_unsigned(did_obj, mst_root, rev, None); + let signed_commit = match unsigned_commit.sign(&secret_key) { + Ok(c) => c, + Err(e) => { + error!("Error signing genesis commit: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let commit_bytes = match signed_commit.to_cbor() { + Ok(b) => b, + Err(e) => { + error!("Error serializing genesis commit: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { + Ok(c) => c, + Err(e) => { + error!("Error saving genesis commit: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let commit_cid_str = commit_cid.to_string(); + if let Err(e) = sqlx::query!( + "INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)", + user_id, + commit_cid_str + ) + .execute(&mut *tx) + .await + { + error!("Error inserting repo: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Some(ref code) = input.invite_code { + let _ = sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&mut *tx) + .await; + + let _ = sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + user_id + ) + .execute(&mut *tx) + .await; + } + + if let Err(e) = sqlx::query!( + "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)", + user_id, + verification_channel as _, + verification_code, + verification_recipient, + verification_code_expires_at + ) + .execute(&mut *tx) + .await + { + error!("Error inserting channel verification: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Err(e) = tx.commit().await { + error!("Error committing transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Err(e) = + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await + { + warn!("Failed to sequence identity event for {}: {}", did, e); + } + + if let Err(e) = crate::comms::enqueue_signup_verification( + &state.db, + user_id, + verification_channel, + &verification_recipient, + &verification_code, + ) + .await + { + warn!("Failed to enqueue signup verification: {:?}", e); + } + + info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); + + Json(CreatePasskeyAccountResponse { + did, + handle, + setup_token, + setup_expires_at, + }) + .into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletePasskeySetupInput { + pub did: String, + pub setup_token: String, + pub passkey_credential: serde_json::Value, + pub passkey_friendly_name: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletePasskeySetupResponse { + pub did: String, + pub handle: String, + pub app_password: String, + pub app_password_name: String, +} + +pub async fn complete_passkey_setup( + State(state): State, + Json(input): Json, +) -> Response { + let user = sqlx::query!( + r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required + FROM users WHERE did = $1"#, + input.did + ) + .fetch_optional(&state.db) + .await; + + let user = match user { + Ok(Some(u)) => u, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if user.password_required { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidAccount", "message": "This account is not a passkey-only account"})), + ) + .into_response(); + } + + let token_hash = match &user.recovery_token { + Some(h) => h, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "SetupExpired", "message": "Setup has already been completed or expired"})), + ) + .into_response(); + } + }; + + if let Some(expires_at) = user.recovery_token_expires_at + && expires_at < Utc::now() + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "SetupExpired", "message": "Setup token has expired"})), + ) + .into_response(); + } + + if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "InvalidToken", "message": "Invalid setup token"})), + ) + .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) => { + error!("Failed to create WebAuthn config: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let reg_state = match crate::auth::webauthn::load_registration_state(&state.db, &input.did) + .await + { + Ok(Some(s)) => s, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "NoChallengeInProgress", "message": "Please start passkey registration first"})), + ) + .into_response(); + } + Err(e) => { + error!("Error loading registration state: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = match serde_json::from_value( + input.passkey_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"}), + ), + ) + .into_response(); + } + }; + + let security_key = match webauthn.finish_registration(&credential, ®_state) { + Ok(sk) => sk, + Err(e) => { + warn!("Passkey registration failed: {:?}", e); + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "RegistrationFailed", "message": "Passkey registration failed"})), + ) + .into_response(); + } + }; + + if let Err(e) = crate::auth::webauthn::save_passkey( + &state.db, + &input.did, + &security_key, + input.passkey_friendly_name.as_deref(), + ) + .await + { + error!("Error saving passkey: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; + + let app_password = generate_app_password(); + let app_password_name = "bsky.app".to_string(); + let password_hash = match hash(&app_password, DEFAULT_COST) { + Ok(h) => h, + Err(e) => { + error!("Error hashing app password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", + user.id, + app_password_name, + password_hash + ) + .execute(&state.db) + .await + { + error!("Error creating app password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!( + "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", + input.did + ) + .execute(&state.db) + .await + { + error!("Error clearing setup token: {:?}", e); + } + + info!(did = %input.did, "Passkey-only account setup completed"); + + Json(CompletePasskeySetupResponse { + did: input.did, + handle: user.handle, + app_password, + app_password_name, + }) + .into_response() +} + +pub async fn start_passkey_registration_for_setup( + State(state): State, + Json(input): Json, +) -> Response { + let user = sqlx::query!( + r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required + FROM users WHERE did = $1"#, + input.did + ) + .fetch_optional(&state.db) + .await; + + let user = match user { + Ok(Some(u)) => u, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if user.password_required { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidAccount"})), + ) + .into_response(); + } + + let token_hash = match &user.recovery_token { + Some(h) => h, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "SetupExpired"})), + ) + .into_response(); + } + }; + + if let Some(expires_at) = user.recovery_token_expires_at + && expires_at < Utc::now() + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "SetupExpired"})), + ) + .into_response(); + } + + if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "InvalidToken"})), + ) + .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) => { + error!("Failed to create WebAuthn config: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let existing_passkeys = crate::auth::webauthn::get_passkeys_for_user(&state.db, &input.did) + .await + .unwrap_or_default(); + + let exclude_credentials: Vec = existing_passkeys + .iter() + .map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone())) + .collect(); + + let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle); + + let (ccr, reg_state) = match webauthn.start_registration( + &input.did, + &user.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"})), + ) + .into_response(); + } + }; + + if let Err(e) = + crate::auth::webauthn::save_registration_state(&state.db, &input.did, ®_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!({})); + Json(json!({"options": options})).into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartPasskeyRegistrationInput { + pub did: String, + pub setup_token: String, + pub friendly_name: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestPasskeyRecoveryInput { + #[serde(alias = "identifier")] + pub email: String, +} + +pub async fn request_passkey_recovery( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let client_ip = extract_client_ip(&headers); + if !state + .check_rate_limit(RateLimitKind::PasswordReset, &client_ip) + .await + { + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({"error": "RateLimitExceeded"})), + ) + .into_response(); + } + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let identifier = input.email.trim().to_lowercase(); + let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); + let normalized_handle = if identifier.contains('@') || identifier.contains('.') { + identifier.to_string() + } else { + format!("{}.{}", identifier, pds_hostname) + }; + + let user = sqlx::query!( + "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2", + identifier, + normalized_handle + ) + .fetch_optional(&state.db) + .await; + + let user = match user { + Ok(Some(u)) if !u.password_required => u, + _ => { + return Json(json!({"success": true})).into_response(); + } + }; + + let recovery_token = generate_setup_token(); + let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) { + Ok(h) => h, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + let expires_at = Utc::now() + Duration::hours(1); + + if let Err(e) = sqlx::query!( + "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3", + recovery_token_hash, + expires_at, + user.did + ) + .execute(&state.db) + .await + { + error!("Error updating recovery token: {:?}", 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 recovery_url = format!( + "https://{}/#/recover-passkey?did={}&token={}", + hostname, + urlencoding::encode(&user.did), + urlencoding::encode(&recovery_token) + ); + + let _ = + crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await; + + info!(did = %user.did, "Passkey recovery requested"); + Json(json!({"success": true})).into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecoverPasskeyAccountInput { + pub did: String, + pub recovery_token: String, + pub new_password: String, +} + +pub async fn recover_passkey_account( + State(state): State, + Json(input): Json, +) -> Response { + if input.new_password.len() < 8 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "WeakPassword", "message": "Password must be at least 8 characters"})), + ) + .into_response(); + } + + let user = sqlx::query!( + "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", + input.did + ) + .fetch_optional(&state.db) + .await; + + let user = match user { + Ok(Some(u)) => u, + _ => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "InvalidRecoveryLink"})), + ) + .into_response(); + } + }; + + let token_hash = match &user.recovery_token { + Some(h) => h, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRecoveryLink"})), + ) + .into_response(); + } + }; + + if let Some(expires_at) = user.recovery_token_expires_at + && expires_at < Utc::now() + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "RecoveryLinkExpired"})), + ) + .into_response(); + } + + if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "InvalidRecoveryLink"})), + ) + .into_response(); + } + + let password_hash = match hash(&input.new_password, DEFAULT_COST) { + Ok(h) => h, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", + password_hash, + input.did + ) + .execute(&state.db) + .await + { + error!("Error updating password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did) + .execute(&state.db) + .await; + match deleted { + Ok(result) => { + if result.rows_affected() > 0 { + info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery"); + } + } + Err(e) => { + warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e); + } + } + + info!(did = %input.did, "Passkey-only account recovered with temporary password"); + Json(json!({"success": true})).into_response() +} diff --git a/src/api/server/passkeys.rs b/src/api/server/passkeys.rs index d853fb8..909b3f8 100644 --- a/src/api/server/passkeys.rs +++ b/src/api/server/passkeys.rs @@ -371,7 +371,9 @@ pub async fn update_passkey( } pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool { - webauthn::has_passkeys(&state.db, did) - .await - .unwrap_or(false) + has_passkeys_for_user_db(&state.db, did).await +} + +pub async fn has_passkeys_for_user_db(db: &sqlx::PgPool, did: &str) -> bool { + webauthn::has_passkeys(db, did).await.unwrap_or(false) } diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 611aa2e..2a3255f 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -33,6 +33,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String { #[derive(Deserialize)] pub struct RequestPasswordResetInput { + #[serde(alias = "identifier")] pub email: String, } @@ -56,21 +57,33 @@ pub async fn request_password_reset( ) .into_response(); } - let email = input.email.trim().to_lowercase(); - if email.is_empty() { + let identifier = input.email.trim(); + if identifier.is_empty() { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "email is required"})), + Json(json!({"error": "InvalidRequest", "message": "email or handle is required"})), ) .into_response(); } - let user = sqlx::query!("SELECT id FROM users WHERE LOWER(email) = $1", email) - .fetch_optional(&state.db) - .await; + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let normalized = identifier.to_lowercase(); + let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); + let normalized_handle = if normalized.contains('@') || normalized.contains('.') { + normalized.to_string() + } else { + format!("{}.{}", normalized, pds_hostname) + }; + let user = sqlx::query!( + "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2", + normalized, + normalized_handle + ) + .fetch_optional(&state.db) + .await; let user_id = match user { Ok(Some(row)) => row.id, Ok(None) => { - info!("Password reset requested for unknown email"); + info!("Password reset requested for unknown identifier"); return (StatusCode::OK, Json(json!({}))).into_response(); } Err(e) => { @@ -225,7 +238,7 @@ pub async fn reset_password( } }; if let Err(e) = sqlx::query!( - "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2", + "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2", password_hash, user_id ) @@ -404,3 +417,105 @@ pub async fn change_password( info!(did = %auth.0.did, "Password changed successfully"); (StatusCode::OK, Json(json!({}))).into_response() } + +pub async fn get_password_status(State(state): State, auth: BearerAuth) -> Response { + let user = sqlx::query!( + "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1", + auth.0.did + ) + .fetch_optional(&state.db) + .await; + + match user { + Ok(Some(row)) => { + Json(json!({"hasPassword": row.has_password.unwrap_or(false)})).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound"})), + ) + .into_response(), + Err(e) => { + error!("DB error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +pub async fn remove_password(State(state): State, auth: BearerAuth) -> Response { + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; + } + + let has_passkeys = + crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await; + if !has_passkeys { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "NoPasskeys", + "message": "You must have at least one passkey registered before removing your password" + })), + ) + .into_response(); + } + + let user = sqlx::query!( + "SELECT id, password_hash FROM users WHERE did = $1", + auth.0.did + ) + .fetch_optional(&state.db) + .await; + + let user = match user { + Ok(Some(u)) => u, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if user.password_hash.is_none() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "NoPassword", + "message": "Account already has no password" + })), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!( + "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1", + user.id + ) + .execute(&state.db) + .await + { + error!("DB error removing password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + info!(did = %auth.0.did, "Password removed - account is now passkey-only"); + (StatusCode::OK, Json(json!({"success": true}))).into_response() +} diff --git a/src/api/server/reauth.rs b/src/api/server/reauth.rs new file mode 100644 index 0000000..d73fb1f --- /dev/null +++ b/src/api/server/reauth.rs @@ -0,0 +1,482 @@ +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use tracing::{error, info, warn}; + +use crate::auth::BearerAuth; +use crate::state::AppState; + +const REAUTH_WINDOW_SECONDS: i64 = 300; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReauthStatusResponse { + pub last_reauth_at: Option>, + pub reauth_required: bool, + pub available_methods: Vec, +} + +pub async fn get_reauth_status(State(state): State, auth: BearerAuth) -> Response { + let session = sqlx::query!( + "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", + auth.0.did + ) + .fetch_optional(&state.db) + .await; + + let last_reauth_at = match session { + Ok(Some(row)) => row.last_reauth_at, + Ok(None) => None, + Err(e) => { + error!("DB error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let reauth_required = is_reauth_required(last_reauth_at); + let available_methods = get_available_reauth_methods(&state.db, &auth.0.did).await; + + Json(ReauthStatusResponse { + last_reauth_at, + reauth_required, + available_methods, + }) + .into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasswordReauthInput { + pub password: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReauthResponse { + pub reauthed_at: DateTime, +} + +pub async fn reauth_password( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> 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"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let password_valid = password_hash + .as_ref() + .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) + .unwrap_or(false); + + if !password_valid { + let app_passwords = sqlx::query!( + "SELECT ap.password_hash FROM app_passwords ap + JOIN users u ON ap.user_id = u.id + WHERE u.did = $1", + auth.0.did + ) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + let app_password_valid = app_passwords + .iter() + .any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false)); + + if !app_password_valid { + warn!(did = %auth.0.did, "Re-auth failed: invalid password"); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "InvalidPassword", + "message": "Password is incorrect" + })), + ) + .into_response(); + } + } + + match update_last_reauth(&state.db, &auth.0.did).await { + Ok(reauthed_at) => { + info!(did = %auth.0.did, "Re-auth successful via password"); + Json(ReauthResponse { reauthed_at }).into_response() + } + Err(e) => { + error!("DB error updating reauth: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TotpReauthInput { + pub code: String, +} + +pub async fn reauth_totp( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let valid = + crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code) + .await; + + if !valid { + warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code"); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "InvalidCode", + "message": "Invalid TOTP or backup code" + })), + ) + .into_response(); + } + + match update_last_reauth(&state.db, &auth.0.did).await { + Ok(reauthed_at) => { + info!(did = %auth.0.did, "Re-auth successful via TOTP"); + Json(ReauthResponse { reauthed_at }).into_response() + } + Err(e) => { + error!("DB error updating reauth: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyReauthStartResponse { + pub options: serde_json::Value, +} + +pub async fn reauth_passkey_start(State(state): State, auth: BearerAuth) -> Response { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + + let stored_passkeys = + match crate::auth::webauthn::get_passkeys_for_user(&state.db, &auth.0.did).await { + Ok(pks) => pks, + Err(e) => { + error!("Failed to get passkeys: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if stored_passkeys.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "NoPasskeys", + "message": "No passkeys registered for this account" + })), + ) + .into_response(); + } + + let passkeys: Vec = stored_passkeys + .iter() + .filter_map(|sp| sp.to_security_key().ok()) + .collect(); + + if passkeys.is_empty() { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to load passkeys"})), + ) + .into_response(); + } + + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { + Ok(w) => w, + Err(e) => { + error!("Failed to create WebAuthn config: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { + Ok(result) => result, + Err(e) => { + error!("Failed to start passkey authentication: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = + crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await + { + error!("Failed to save authentication state: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + let options = serde_json::to_value(&rcr).unwrap_or(json!({})); + Json(PasskeyReauthStartResponse { options }).into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyReauthFinishInput { + pub credential: serde_json::Value, +} + +pub async fn reauth_passkey_finish( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + + let auth_state = + match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await { + Ok(Some(s)) => s, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "NoChallengeInProgress", + "message": "No passkey authentication in progress or challenge expired" + })), + ) + .into_response(); + } + Err(e) => { + error!("Failed to load authentication state: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let credential: webauthn_rs::prelude::PublicKeyCredential = + 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 webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { + Ok(w) => w, + Err(e) => { + error!("Failed to create WebAuthn config: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { + Ok(r) => r, + Err(e) => { + warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "AuthenticationFailed", + "message": "Passkey authentication failed" + })), + ) + .into_response(); + } + }; + + let cred_id_bytes = auth_result.cred_id().as_ref(); + if let Err(e) = crate::auth::webauthn::update_passkey_counter( + &state.db, + cred_id_bytes, + auth_result.counter(), + ) + .await + { + error!("Failed to update passkey counter: {:?}", e); + } + + let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; + + match update_last_reauth(&state.db, &auth.0.did).await { + Ok(reauthed_at) => { + info!(did = %auth.0.did, "Re-auth successful via passkey"); + Json(ReauthResponse { reauthed_at }).into_response() + } + Err(e) => { + error!("DB error updating reauth: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +async fn update_last_reauth(db: &PgPool, did: &str) -> Result, sqlx::Error> { + let now = Utc::now(); + sqlx::query!( + "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", + now, + did + ) + .execute(db) + .await?; + Ok(now) +} + +fn is_reauth_required(last_reauth_at: Option>) -> bool { + match last_reauth_at { + None => true, + Some(t) => { + let elapsed = Utc::now().signed_duration_since(t); + elapsed.num_seconds() > REAUTH_WINDOW_SECONDS + } + } +} + +async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec { + let mut methods = Vec::new(); + + let has_password = sqlx::query_scalar!( + "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1", + did + ) + .fetch_optional(db) + .await + .ok() + .flatten() + .unwrap_or(Some(false)); + + if has_password == Some(true) { + methods.push("password".to_string()); + } + + let has_app_password = sqlx::query_scalar!( + "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", + did + ) + .fetch_optional(db) + .await + .ok() + .flatten() + .is_some(); + + if has_app_password && !methods.contains(&"password".to_string()) { + methods.push("password".to_string()); + } + + let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await; + if has_totp { + methods.push("totp".to_string()); + } + + let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await; + if has_passkeys { + methods.push("passkey".to_string()); + } + + methods +} + +pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool { + let session = sqlx::query!( + "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", + did + ) + .fetch_optional(db) + .await; + + match session { + Ok(Some(row)) => is_reauth_required(row.last_reauth_at), + _ => true, + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReauthRequiredError { + pub error: String, + pub message: String, + pub reauth_methods: Vec, +} + +pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response { + let methods = get_available_reauth_methods(db, did).await; + ( + StatusCode::UNAUTHORIZED, + Json(ReauthRequiredError { + error: "ReauthRequired".to_string(), + message: "Re-authentication required for this action".to_string(), + reauth_methods: methods, + }), + ) + .into_response() +} diff --git a/src/api/server/session.rs b/src/api/server/session.rs index ca662ab..2f485d3 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -63,7 +63,10 @@ pub async fn create_session( headers: HeaderMap, Json(input): Json, ) -> Response { - info!("create_session called with identifier: {}", input.identifier); + info!( + "create_session called with identifier: {}", + input.identifier + ); let client_ip = extract_client_ip(&headers); if !state .check_rate_limit(RateLimitKind::Login, &client_ip) @@ -81,7 +84,10 @@ pub async fn create_session( } let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname); - info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier); + info!( + "Normalized identifier: {} -> {}", + input.identifier, normalized_identifier + ); let row = match sqlx::query!( r#"SELECT u.id, u.did, u.handle, u.password_hash, @@ -117,7 +123,12 @@ pub async fn create_session( return ApiError::InternalError.into_response(); } }; - let password_valid = if verify(&input.password, &row.password_hash).unwrap_or(false) { + let password_valid = if row + .password_hash + .as_ref() + .map(|h| verify(&input.password, h).unwrap_or(false)) + .unwrap_or(false) + { true } else { let app_passwords = sqlx::query!( @@ -523,9 +534,16 @@ pub async fn confirm_signup( } }; + let channel_str = match row.channel { + crate::comms::CommsChannel::Email => "email", + crate::comms::CommsChannel::Discord => "discord", + crate::comms::CommsChannel::Telegram => "telegram", + crate::comms::CommsChannel::Signal => "signal", + }; let verification = match sqlx::query!( - "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", - row.id + "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", + row.id, + channel_str as _ ) .fetch_optional(&state.db) .await @@ -574,8 +592,9 @@ pub async fn confirm_signup( } if let Err(e) = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", - row.id + "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", + row.id, + channel_str as _ ) .execute(&state.db) .await @@ -676,18 +695,31 @@ pub async fn resend_verification( let verification_code = format!("{:06}", rand::random::() % 1_000_000); let code_expires_at = Utc::now() + chrono::Duration::minutes(30); - let email = row.email.clone(); + let (channel_str, recipient) = match row.channel { + crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), + crate::comms::CommsChannel::Discord => { + ("discord", row.discord_id.clone().unwrap_or_default()) + } + crate::comms::CommsChannel::Telegram => ( + "telegram", + row.telegram_username.clone().unwrap_or_default(), + ), + crate::comms::CommsChannel::Signal => { + ("signal", row.signal_number.clone().unwrap_or_default()) + } + }; if let Err(e) = sqlx::query!( r#" INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) - VALUES ($1, 'email', $2, $3, $4) + VALUES ($1, $2::comms_channel, $3, $4, $5) ON CONFLICT (user_id, channel) DO UPDATE - SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW() + SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW() "#, row.id, + channel_str as _, verification_code, - email, + recipient, code_expires_at ) .execute(&state.db) @@ -696,14 +728,6 @@ pub async fn resend_verification( error!("Failed to update verification code: {:?}", e); return ApiError::InternalError.into_response(); } - let (channel_str, recipient) = match row.channel { - crate::comms::CommsChannel::Email => ("email", row.email.unwrap_or_default()), - crate::comms::CommsChannel::Discord => ("discord", row.discord_id.unwrap_or_default()), - crate::comms::CommsChannel::Telegram => { - ("telegram", row.telegram_username.unwrap_or_default()) - } - crate::comms::CommsChannel::Signal => ("signal", row.signal_number.unwrap_or_default()), - }; if let Err(e) = crate::comms::enqueue_signup_verification( &state.db, row.id, diff --git a/src/api/server/totp.rs b/src/api/server/totp.rs index 1f66817..9d54220 100644 --- a/src/api/server/totp.rs +++ b/src/api/server/totp.rs @@ -332,7 +332,10 @@ pub async fn disable_totp( } }; - let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); + let password_valid = password_hash + .as_ref() + .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) + .unwrap_or(false); if !password_valid { return ( StatusCode::UNAUTHORIZED, @@ -536,7 +539,10 @@ pub async fn regenerate_backup_codes( } }; - let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); + let password_valid = password_hash + .as_ref() + .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) + .unwrap_or(false); if !password_valid { return ( StatusCode::UNAUTHORIZED, @@ -741,8 +747,12 @@ pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: & } pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool { + has_totp_enabled_db(&state.db, did).await +} + +pub async fn has_totp_enabled_db(db: &sqlx::PgPool, did: &str) -> bool { let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did) - .fetch_optional(&state.db) + .fetch_optional(db) .await; matches!(result, Ok(Some(true))) diff --git a/src/api/server/trusted_devices.rs b/src/api/server/trusted_devices.rs new file mode 100644 index 0000000..48b2f57 --- /dev/null +++ b/src/api/server/trusted_devices.rs @@ -0,0 +1,246 @@ +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use tracing::{error, info}; + +use crate::auth::BearerAuth; +use crate::state::AppState; + +const TRUST_DURATION_DAYS: i64 = 30; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TrustedDevice { + pub id: String, + pub user_agent: Option, + pub friendly_name: Option, + pub trusted_at: Option>, + pub trusted_until: Option>, + pub last_seen_at: DateTime, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListTrustedDevicesResponse { + pub devices: Vec, +} + +pub async fn list_trusted_devices(State(state): State, auth: BearerAuth) -> Response { + let devices = sqlx::query!( + r#"SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at + FROM oauth_device od + JOIN oauth_account_device oad ON od.id = oad.device_id + WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW() + ORDER BY od.last_seen_at DESC"#, + auth.0.did + ) + .fetch_all(&state.db) + .await; + + match devices { + Ok(rows) => { + let devices = rows + .into_iter() + .map(|row| TrustedDevice { + id: row.id, + user_agent: row.user_agent, + friendly_name: row.friendly_name, + trusted_at: row.trusted_at, + trusted_until: row.trusted_until, + last_seen_at: row.last_seen_at, + }) + .collect(); + Json(ListTrustedDevicesResponse { devices }).into_response() + } + Err(e) => { + error!("DB error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevokeTrustedDeviceInput { + pub device_id: String, +} + +pub async fn revoke_trusted_device( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let device_exists = sqlx::query_scalar!( + r#"SELECT 1 as one FROM oauth_device od + JOIN oauth_account_device oad ON od.id = oad.device_id + WHERE oad.did = $1 AND od.id = $2"#, + auth.0.did, + input.device_id + ) + .fetch_optional(&state.db) + .await; + + match device_exists { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + + let result = sqlx::query!( + "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1", + input.device_id + ) + .execute(&state.db) + .await; + + match result { + Ok(_) => { + info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device revoked"); + Json(json!({"success": true})).into_response() + } + Err(e) => { + error!("DB error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTrustedDeviceInput { + pub device_id: String, + pub friendly_name: Option, +} + +pub async fn update_trusted_device( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let device_exists = sqlx::query_scalar!( + r#"SELECT 1 as one FROM oauth_device od + JOIN oauth_account_device oad ON od.id = oad.device_id + WHERE oad.did = $1 AND od.id = $2"#, + auth.0.did, + input.device_id + ) + .fetch_optional(&state.db) + .await; + + match device_exists { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + + let result = sqlx::query!( + "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2", + input.friendly_name, + input.device_id + ) + .execute(&state.db) + .await; + + match result { + Ok(_) => { + info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated"); + Json(json!({"success": true})).into_response() + } + Err(e) => { + error!("DB error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool { + let result = sqlx::query_scalar!( + r#"SELECT trusted_until FROM oauth_device od + JOIN oauth_account_device oad ON od.id = oad.device_id + WHERE od.id = $1 AND oad.did = $2"#, + device_id, + did + ) + .fetch_optional(db) + .await; + + match result { + Ok(Some(Some(trusted_until))) => trusted_until > Utc::now(), + _ => false, + } +} + +pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> { + let now = Utc::now(); + let trusted_until = now + Duration::days(TRUST_DURATION_DAYS); + + sqlx::query!( + "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3", + now, + trusted_until, + device_id + ) + .execute(db) + .await?; + + Ok(()) +} + +pub async fn extend_device_trust(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> { + let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS); + + sqlx::query!( + "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL", + trusted_until, + device_id + ) + .execute(db) + .await?; + + Ok(()) +} diff --git a/src/api/validation.rs b/src/api/validation.rs index e3bfe38..f1fa9ee 100644 --- a/src/api/validation.rs +++ b/src/api/validation.rs @@ -22,10 +22,23 @@ impl std::fmt::Display for HandleValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Empty => write!(f, "Handle cannot be empty"), - Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH), - Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH), - Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"), - Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"), + Self::TooShort => write!( + f, + "Handle must be at least {} characters", + MIN_HANDLE_LENGTH + ), + Self::TooLong => write!( + f, + "Handle exceeds maximum length of {} characters", + MAX_HANDLE_LENGTH + ), + Self::InvalidCharacters => write!( + f, + "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed" + ), + Self::StartsWithInvalidChar => { + write!(f, "Handle cannot start with a hyphen or underscore") + } Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"), Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), } @@ -125,28 +138,76 @@ mod tests { fn test_valid_handles() { assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string())); - assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string())); - assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string())); - assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string())); - assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string())); + assert_eq!( + validate_short_handle("user-name"), + Ok("user-name".to_string()) + ); + assert_eq!( + validate_short_handle("user_name"), + Ok("user_name".to_string()) + ); + assert_eq!( + validate_short_handle("UPPERCASE"), + Ok("uppercase".to_string()) + ); + assert_eq!( + validate_short_handle("MixedCase123"), + Ok("mixedcase123".to_string()) + ); assert_eq!(validate_short_handle("abc"), Ok("abc".to_string())); } #[test] fn test_invalid_handles() { assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty)); - assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty)); - assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort)); - assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort)); - assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces)); - assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces)); - assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar)); - assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar)); - assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar)); - assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar)); - assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters)); - assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters)); - assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters)); + assert_eq!( + validate_short_handle(" "), + Err(HandleValidationError::Empty) + ); + assert_eq!( + validate_short_handle("ab"), + Err(HandleValidationError::TooShort) + ); + assert_eq!( + validate_short_handle("a"), + Err(HandleValidationError::TooShort) + ); + assert_eq!( + validate_short_handle("test spaces"), + Err(HandleValidationError::ContainsSpaces) + ); + assert_eq!( + validate_short_handle("test\ttab"), + Err(HandleValidationError::ContainsSpaces) + ); + assert_eq!( + validate_short_handle("-starts"), + Err(HandleValidationError::StartsWithInvalidChar) + ); + assert_eq!( + validate_short_handle("_starts"), + Err(HandleValidationError::StartsWithInvalidChar) + ); + assert_eq!( + validate_short_handle("ends-"), + Err(HandleValidationError::EndsWithInvalidChar) + ); + assert_eq!( + validate_short_handle("ends_"), + Err(HandleValidationError::EndsWithInvalidChar) + ); + assert_eq!( + validate_short_handle("test@user"), + Err(HandleValidationError::InvalidCharacters) + ); + assert_eq!( + validate_short_handle("test!user"), + Err(HandleValidationError::InvalidCharacters) + ); + assert_eq!( + validate_short_handle("test.user"), + Err(HandleValidationError::InvalidCharacters) + ); } #[test] diff --git a/src/comms/mod.rs b/src/comms/mod.rs index cf39102..1287b10 100644 --- a/src/comms/mod.rs +++ b/src/comms/mod.rs @@ -9,8 +9,8 @@ pub use sender::{ pub use service::{ CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, - enqueue_email_update, enqueue_email_verification, enqueue_password_reset, - enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, + enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery, + enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, }; pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms}; diff --git a/src/comms/service.rs b/src/comms/service.rs index 099171b..ed21cb2 100644 --- a/src/comms/service.rs +++ b/src/comms/service.rs @@ -457,6 +457,31 @@ pub async fn enqueue_2fa_code( .await } +pub async fn enqueue_passkey_recovery( + db: &PgPool, + user_id: Uuid, + recovery_url: &str, + hostname: &str, +) -> Result { + let prefs = get_user_comms_prefs(db, user_id).await?; + let body = format!( + "Hello @{},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.", + prefs.handle, recovery_url + ); + enqueue_comms( + db, + NewComms::new( + user_id, + prefs.channel, + super::types::CommsType::PasskeyRecovery, + prefs.email.clone().unwrap_or_default(), + Some(format!("Account Recovery - {}", hostname)), + body, + ), + ) + .await +} + pub fn channel_display_name(channel: CommsChannel) -> &'static str { match channel { CommsChannel::Email => "email", diff --git a/src/comms/types.rs b/src/comms/types.rs index 4c3d6e2..de314b5 100644 --- a/src/comms/types.rs +++ b/src/comms/types.rs @@ -32,6 +32,7 @@ pub enum CommsType { AdminEmail, PlcOperation, TwoFactorCode, + PasskeyRecovery, } #[derive(Debug, Clone, FromRow)] diff --git a/src/lib.rs b/src/lib.rs index 6616a57..1eeb62e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -202,6 +202,66 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.tranquil.account.changePassword", post(api::server::change_password), ) + .route( + "/xrpc/com.tranquil.account.removePassword", + post(api::server::remove_password), + ) + .route( + "/xrpc/com.tranquil.account.getPasswordStatus", + get(api::server::get_password_status), + ) + .route( + "/xrpc/com.tranquil.account.getReauthStatus", + get(api::server::get_reauth_status), + ) + .route( + "/xrpc/com.tranquil.account.reauthPassword", + post(api::server::reauth_password), + ) + .route( + "/xrpc/com.tranquil.account.reauthTotp", + post(api::server::reauth_totp), + ) + .route( + "/xrpc/com.tranquil.account.reauthPasskeyStart", + post(api::server::reauth_passkey_start), + ) + .route( + "/xrpc/com.tranquil.account.reauthPasskeyFinish", + post(api::server::reauth_passkey_finish), + ) + .route( + "/xrpc/com.tranquil.account.listTrustedDevices", + get(api::server::list_trusted_devices), + ) + .route( + "/xrpc/com.tranquil.account.revokeTrustedDevice", + post(api::server::revoke_trusted_device), + ) + .route( + "/xrpc/com.tranquil.account.updateTrustedDevice", + post(api::server::update_trusted_device), + ) + .route( + "/xrpc/com.tranquil.account.createPasskeyAccount", + post(api::server::create_passkey_account), + ) + .route( + "/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup", + post(api::server::start_passkey_registration_for_setup), + ) + .route( + "/xrpc/com.tranquil.account.completePasskeySetup", + post(api::server::complete_passkey_setup), + ) + .route( + "/xrpc/com.tranquil.account.requestPasskeyRecovery", + post(api::server::request_passkey_recovery), + ) + .route( + "/xrpc/com.tranquil.account.recoverPasskeyAccount", + post(api::server::recover_passkey_account), + ) .route( "/xrpc/com.atproto.server.requestEmailUpdate", post(api::server::request_email_update), @@ -399,6 +459,14 @@ pub fn app(state: AppState) -> Router { "/oauth/authorize/2fa", post(oauth::endpoints::authorize_2fa_post), ) + .route( + "/oauth/authorize/passkey", + get(oauth::endpoints::authorize_passkey_start), + ) + .route( + "/oauth/authorize/passkey", + post(oauth::endpoints::authorize_passkey_finish), + ) .route( "/oauth/passkey/check", get(oauth::endpoints::check_user_has_passkeys), diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index 63d2180..0a0864e 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -441,7 +441,7 @@ pub async fn authorize_post( ); let user = match sqlx::query!( r#" - SELECT id, did, email, password_hash, two_factor_enabled, + SELECT id, did, email, password_hash, password_required, two_factor_enabled, preferred_comms_channel as "preferred_comms_channel: CommsChannel", deactivated_at, takedown_ref, email_verified, discord_verified, telegram_verified, signal_verified @@ -479,31 +479,74 @@ pub async fn authorize_post( json_response, ); } - let password_valid = match bcrypt::verify(&form.password, &user.password_hash) { - Ok(valid) => valid, - Err(_) => return show_login_error("An error occurred. Please try again.", json_response), - }; - 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 !user.password_required { 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={}", + let redirect_url = format!( + "/#/oauth/passkey?request_uri={}", url_encode(&form.request_uri) - )); + ); + if json_response { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "next": "passkey", + "redirect": redirect_url + })), + ) + .into_response(); + } + return redirect_see_other(&redirect_url); + } + + let password_valid = match &user.password_hash { + Some(hash) => match bcrypt::verify(&form.password, hash) { + Ok(valid) => valid, + Err(_) => { + return show_login_error("An error occurred. Please try again.", json_response); + } + }, + None => false, + }; + 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 { + let device_cookie = extract_device_cookie(&headers); + let device_is_trusted = if let Some(ref dev_id) = device_cookie { + crate::api::server::is_device_trusted(&state.db, dev_id, &user.did).await + } else { + false + }; + + if device_is_trusted { + if let Some(ref dev_id) = device_cookie { + let _ = crate::api::server::extend_device_trust(&state.db, dev_id).await; + } + } else { + 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; @@ -934,6 +977,8 @@ pub struct Authorize2faQuery { pub struct Authorize2faSubmit { pub request_uri: String, pub code: String, + #[serde(default)] + pub trust_device: bool, } const MAX_2FA_ATTEMPTS: i32 = 5; @@ -1475,6 +1520,12 @@ pub async fn authorize_2fa_post( "Invalid verification code. Please try again.", ); } + let device_id = extract_device_cookie(&headers); + if form.trust_device + && let Some(ref dev_id) = device_id + { + let _ = crate::api::server::trust_device(&state.db, dev_id).await; + } let requested_scope_str = request_data .parameters .scope @@ -1500,7 +1551,6 @@ pub async fn authorize_2fa_post( 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, @@ -2139,3 +2189,371 @@ pub async fn passkey_finish( })) .into_response() } + +#[derive(Debug, Deserialize)] +pub struct AuthorizePasskeyQuery { + pub request_uri: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAuthResponse { + pub options: serde_json::Value, + pub request_uri: String, +} + +pub async fn authorize_passkey_start( + State(state): State, + Query(query): Query, +) -> Response { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + + let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Authorization request not found." + })), + ) + .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, &query.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.clone(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "User not authenticated yet." + })), + ) + .into_response(); + } + }; + + let stored_passkeys = match crate::auth::webauthn::get_passkeys_for_user(&state.db, &did).await + { + Ok(pks) => pks, + Err(e) => { + tracing::error!("Failed to get passkeys: {:?}", e); + 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::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "No passkeys registered for this account." + })), + ) + .into_response(); + } + + let passkeys: Vec = 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!("Failed to create WebAuthn config: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ) + .into_response(); + } + }; + + let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { + Ok(result) => result, + Err(e) => { + tracing::error!("Failed to start passkey authentication: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ) + .into_response(); + } + }; + + if let Err(e) = + crate::auth::webauthn::save_authentication_state(&state.db, &did, &auth_state).await + { + tracing::error!("Failed to save authentication state: {:?}", e); + 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(PasskeyAuthResponse { + options, + request_uri: query.request_uri, + }) + .into_response() +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizePasskeySubmit { + pub request_uri: String, + pub credential: serde_json::Value, +} + +pub async fn authorize_passkey_finish( + State(state): State, + headers: HeaderMap, + Json(form): Json, +) -> Response { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { + Ok(Some(d)) => d, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Authorization request not found." + })), + ) + .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.clone(), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "User not authenticated yet." + })), + ) + .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 challenge found. Please start over." + })), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to load authentication state: {:?}", e); + 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.clone()) { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to parse credential: {:?}", e); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Invalid credential format." + })), + ) + .into_response(); + } + }; + + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { + Ok(w) => w, + Err(e) => { + tracing::error!("Failed to create WebAuthn config: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ) + .into_response(); + } + }; + + let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { + Ok(r) => r, + Err(e) => { + tracing::warn!("Passkey authentication failed: {:?}", e); + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Passkey authentication failed." + })), + ) + .into_response(); + } + }; + + let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await; + + if let Err(e) = crate::auth::webauthn::update_passkey_counter( + &state.db, + credential.id.as_ref(), + auth_result.counter(), + ) + .await + { + tracing::warn!("Failed to update passkey counter: {:?}", e); + } + + let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await; + if has_totp { + let device_cookie = extract_device_cookie(&headers); + let device_is_trusted = if let Some(ref dev_id) = device_cookie { + crate::api::server::is_device_trusted(&state.db, dev_id, &did).await + } else { + false + }; + + if device_is_trusted { + if let Some(ref dev_id) = device_cookie { + let _ = crate::api::server::extend_device_trust(&state.db, dev_id).await; + } + } else { + let user = match sqlx::query!( + r#"SELECT id, preferred_comms_channel as "preferred_comms_channel: CommsChannel" FROM users WHERE did = $1"#, + did + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(u)) => u, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ) + .into_response(); + } + }; + + 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) => { + if let Err(e) = + enqueue_2fa_code(&state.db, user.id, &challenge.code, &pds_hostname).await + { + tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification"); + } + let channel_name = channel_display_name(user.preferred_comms_channel); + let redirect_url = format!( + "/#/oauth/2fa?request_uri={}&channel={}", + url_encode(&form.request_uri), + url_encode(channel_name) + ); + return ( + StatusCode::OK, + Json(serde_json::json!({ + "next": "2fa", + "redirect": redirect_url + })), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})), + ) + .into_response(); + } + } + } + } + + let redirect_url = format!( + "/#/oauth/consent?request_uri={}", + url_encode(&form.request_uri) + ); + ( + StatusCode::OK, + Json(serde_json::json!({ + "next": "consent", + "redirect": redirect_url + })), + ) + .into_response() +} diff --git a/tests/admin_search.rs b/tests/admin_search.rs index a0b30c1..cbf81b3 100644 --- a/tests/admin_search.rs +++ b/tests/admin_search.rs @@ -10,24 +10,43 @@ async fn test_search_accounts_as_admin() { let client = client(); let (admin_jwt, _) = create_admin_account_and_login(&client).await; let (user_did, _) = setup_new_user("search-target").await; - let res = client - .get(format!( - "{}/xrpc/com.atproto.admin.searchAccounts?limit=1000", - base_url().await - )) - .bearer_auth(&admin_jwt) - .send() - .await - .expect("Failed to send request"); - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.unwrap(); - let accounts = body["accounts"] - .as_array() - .expect("accounts should be array"); - assert!(!accounts.is_empty(), "Should return some accounts"); - let found = accounts - .iter() - .any(|a| a["did"].as_str() == Some(&user_did)); + let mut found = false; + let mut cursor: Option = None; + for _ in 0..10 { + let url = match &cursor { + Some(c) => format!( + "{}/xrpc/com.atproto.admin.searchAccounts?limit=100&cursor={}", + base_url().await, + c + ), + None => format!( + "{}/xrpc/com.atproto.admin.searchAccounts?limit=100", + base_url().await + ), + }; + let res = client + .get(&url) + .bearer_auth(&admin_jwt) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + let accounts = body["accounts"] + .as_array() + .expect("accounts should be array"); + if accounts + .iter() + .any(|a| a["did"].as_str() == Some(&user_did)) + { + found = true; + break; + } + cursor = body["cursor"].as_str().map(|s| s.to_string()); + if cursor.is_none() { + break; + } + } assert!( found, "Should find the created user in results (DID: {})", diff --git a/tests/did_web.rs b/tests/did_web.rs index 299c430..484c1b5 100644 --- a/tests/did_web.rs +++ b/tests/did_web.rs @@ -97,9 +97,34 @@ async fn test_external_did_web_no_local_doc() { let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); let handle = format!("extweb_{}", uuid::Uuid::new_v4()); let pds_endpoint = base_url().await.replace("http://", "https://"); + + let reserve_res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url().await + )) + .json(&json!({ "did": did })) + .send() + .await + .expect("Failed to reserve signing key"); + assert_eq!(reserve_res.status(), StatusCode::OK); + let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON"); + let signing_key = reserve_body["signingKey"] + .as_str() + .expect("No signingKey returned"); + let public_key_multibase = signing_key + .strip_prefix("did:key:") + .expect("signingKey should start with did:key:"); + let did_doc = json!({ "@context": ["https://www.w3.org/ns/did/v1"], "id": did, + "verificationMethod": [{ + "id": format!("{}#atproto", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": public_key_multibase + }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", @@ -116,7 +141,8 @@ async fn test_external_did_web_no_local_doc() { "email": format!("{}@example.com", handle), "password": "password", "didType": "web-external", - "did": did + "did": did, + "signingKey": signing_key }); let res = client .post(format!( diff --git a/tests/identity.rs b/tests/identity.rs index 04a3958..a97d97c 100644 --- a/tests/identity.rs +++ b/tests/identity.rs @@ -26,7 +26,10 @@ async fn test_resolve_handle_success() { assert_eq!(res.status(), StatusCode::OK); let body: Value = res.json().await.expect("Invalid JSON"); let did = body["did"].as_str().expect("No DID").to_string(); - let full_handle = body["handle"].as_str().expect("No handle in response").to_string(); + let full_handle = body["handle"] + .as_str() + .expect("No handle in response") + .to_string(); let params = [("handle", full_handle.as_str())]; let res = client .get(format!( @@ -97,9 +100,34 @@ async fn test_create_did_web_account_and_resolve() { let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); let handle = format!("webuser_{}", uuid::Uuid::new_v4()); let pds_endpoint = base_url().await.replace("http://", "https://"); + + let reserve_res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url().await + )) + .json(&json!({ "did": did })) + .send() + .await + .expect("Failed to reserve signing key"); + assert_eq!(reserve_res.status(), StatusCode::OK); + let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON"); + let signing_key = reserve_body["signingKey"] + .as_str() + .expect("No signingKey returned"); + let public_key_multibase = signing_key + .strip_prefix("did:key:") + .expect("signingKey should start with did:key:"); + let did_doc = json!({ "@context": ["https://www.w3.org/ns/did/v1"], "id": did, + "verificationMethod": [{ + "id": format!("{}#atproto", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": public_key_multibase + }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", @@ -115,7 +143,8 @@ async fn test_create_did_web_account_and_resolve() { "handle": handle, "email": format!("{}@example.com", handle), "password": "password", - "did": did + "did": did, + "signingKey": signing_key }); let res = client .post(format!( @@ -195,9 +224,34 @@ async fn test_did_web_lifecycle() { let did = format!("did:web:{}:u:{}", mock_addr.replace(":", "%3A"), handle); let email = format!("{}@test.com", handle); let pds_endpoint = base_url().await.replace("http://", "https://"); + + let reserve_res = client + .post(format!( + "{}/xrpc/com.atproto.server.reserveSigningKey", + base_url().await + )) + .json(&json!({ "did": did })) + .send() + .await + .expect("Failed to reserve signing key"); + assert_eq!(reserve_res.status(), StatusCode::OK); + let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON"); + let signing_key = reserve_body["signingKey"] + .as_str() + .expect("No signingKey returned"); + let public_key_multibase = signing_key + .strip_prefix("did:key:") + .expect("signingKey should start with did:key:"); + let did_doc = json!({ "@context": ["https://www.w3.org/ns/did/v1"], "id": did, + "verificationMethod": [{ + "id": format!("{}#atproto", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": public_key_multibase + }], "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", @@ -213,7 +267,8 @@ async fn test_did_web_lifecycle() { "handle": handle, "email": email, "password": "password", - "did": did + "did": did, + "signingKey": signing_key }); let res = client .post(format!(