From d8451f3219fbd2b9d2427b1502acfca1a1058423 Mon Sep 17 00:00:00 2001 From: lewis Date: Wed, 24 Dec 2025 23:57:49 +0200 Subject: [PATCH] 'Clever' stateless token verification. We could revert later if it sucks. --- ...721e44bbf7df1285acc27b0b658841fb55c7e.json | 14 + ...da75c15fb1859c658fc5957ab28ef674108a8.json | 14 - ...580a02c92e579869f647cb08f65ac777854f5.json | 3 +- ...76db6b34925a0c8902fdd9c9a5a607f19b3af.json | 14 + ...fdfa8a2e37aae3f171a2be73bee3057f86e08.json | 3 +- ...4c195768dce6d4c8d297e8b7eab1d990f61c5.json | 15 - ...914ad3bf77d47dd393a0aae1c030b8ce98bcc.json | 3 +- ...200baef1f16427d87fb3afeb1e066c4084483.json | 30 -- ...d4b58b5ab6892846e65a4f1549ae5f147c13e.json | 27 -- ...bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json | 41 -- ...04e0fb78548402715fc88e2b34a8ccdb80082.json | 34 ++ ...4ebca7a6cde24c411597c9df6e94b8be1f922.json | 46 ++ ...e346a95bd3cafe2454dbe08411abb11f42161.json | 14 - ...15e4653e01fd6f5c41f156c844381347cb8a.json} | 24 +- ...fb89b8f937469f8e33a43f2cd8e121526f3d3.json | 34 -- ...896720a4745bfb913a18e4829a8edd73ea005.json | 15 + ...6547360523b12f83e4eb178242edec052a0f2.json | 14 - ...a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a.json | 14 + ...517275989b41befa82347883945f77e8630f6.json | 58 +++ ...820dd37741e9c506d85fc2608a3a6e376c5e6.json | 30 -- ...bd875675779a173fb2cd41ba365c1acca96f9.json | 14 + ...35ec3857ca22e0707ef8911251c4c69e74c87.json | 14 + ...75d341a4c84322e5de2b3630573dbef387839.json | 14 - ...08ac148363e2f98b301dbeeb1caac330528c5.json | 47 -- ...635c06b4179b5858a7b388404c4b03fc92ab4.json | 3 +- frontend/deno.lock | 27 ++ frontend/src/components/ui/Input.svelte | 3 +- frontend/src/components/ui/Section.svelte | 4 - frontend/src/lib/api.ts | 31 +- .../lib/registration/VerificationStep.svelte | 19 +- frontend/src/locales/en.json | 138 +++++- frontend/src/locales/fi.json | 98 +++- frontend/src/locales/ja.json | 100 ++++- frontend/src/locales/ko.json | 100 ++++- frontend/src/locales/sv.json | 112 ++++- frontend/src/locales/zh.json | 138 +++++- frontend/src/routes/Admin.svelte | 142 +++--- frontend/src/routes/Comms.svelte | 11 +- frontend/src/routes/Register.svelte | 14 +- frontend/src/routes/RegisterPasskey.svelte | 186 ++++---- frontend/src/routes/Settings.svelte | 2 +- frontend/src/routes/Verify.svelte | 269 +++++++++-- ..._add_migration_verification_comms_type.sql | 1 + .../20251233_remove_channel_verifications.sql | 1 + src/api/admin/config.rs | 62 +-- src/api/error.rs | 4 +- src/api/identity/account.rs | 146 +++--- src/api/notification_prefs.rs | 92 ++-- src/api/repo/record/utils.rs | 2 +- src/api/server/email.rs | 180 +++----- src/api/server/logo.rs | 23 +- src/api/server/mod.rs | 10 +- src/api/server/passkey_account.rs | 87 ++-- src/api/server/password.rs | 8 +- src/api/server/reauth.rs | 19 +- src/api/server/service_auth.rs | 36 +- src/api/server/session.rs | 124 +++-- src/api/server/verify_email.rs | 101 +++++ src/api/server/verify_token.rs | 391 ++++++++++++++++ src/api/validation.rs | 16 +- src/api/verification.rs | 184 +------- src/auth/mod.rs | 1 + src/auth/verification_token.rs | 423 ++++++++++++++++++ src/comms/locale.rs | 56 +-- src/comms/mod.rs | 2 +- src/comms/service.rs | 115 +++-- src/comms/types.rs | 1 + src/config.rs | 7 +- src/lib.rs | 12 + src/oauth/endpoints/metadata.rs | 3 +- src/rate_limit.rs | 7 +- src/validation/mod.rs | 64 ++- tests/account_notifications.rs | 52 ++- tests/common/mod.rs | 53 ++- tests/did_web.rs | 32 +- tests/email_update.rs | 97 ++-- tests/jwt_security.rs | 23 +- 77 files changed, 3029 insertions(+), 1339 deletions(-) create mode 100644 .sqlx/query-00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e.json delete mode 100644 .sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json create mode 100644 .sqlx/query-1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af.json delete mode 100644 .sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json delete mode 100644 .sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json delete mode 100644 .sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json delete mode 100644 .sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json create mode 100644 .sqlx/query-5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082.json create mode 100644 .sqlx/query-5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922.json delete mode 100644 .sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json rename .sqlx/{query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json => query-9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a.json} (67%) delete mode 100644 .sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json create mode 100644 .sqlx/query-a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005.json delete mode 100644 .sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json create mode 100644 .sqlx/query-b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a.json create mode 100644 .sqlx/query-c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6.json delete mode 100644 .sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json create mode 100644 .sqlx/query-cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9.json create mode 100644 .sqlx/query-d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87.json delete mode 100644 .sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json delete mode 100644 .sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json create mode 100644 migrations/20251232_add_migration_verification_comms_type.sql create mode 100644 migrations/20251233_remove_channel_verifications.sql create mode 100644 src/api/server/verify_email.rs create mode 100644 src/api/server/verify_token.rs create mode 100644 src/auth/verification_token.rs diff --git a/.sqlx/query-00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e.json b/.sqlx/query-00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e.json new file mode 100644 index 0000000..34fa7e4 --- /dev/null +++ b/.sqlx/query-00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET telegram_verified = TRUE WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e" +} diff --git a/.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json b/.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json deleted file mode 100644 index e27197a..0000000 --- a/.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8" -} diff --git a/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json index c73334a..ffe7d8a 100644 --- a/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json +++ b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json @@ -40,7 +40,8 @@ "two_factor_code", "channel_verification", "passkey_recovery", - "legacy_login_alert" + "legacy_login_alert", + "migration_verification" ] } } diff --git a/.sqlx/query-1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af.json b/.sqlx/query-1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af.json new file mode 100644 index 0000000..a7eb7be --- /dev/null +++ b/.sqlx/query-1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET signal_verified = TRUE WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af" +} diff --git a/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json index 5de15c2..6fda80b 100644 --- a/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json +++ b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json @@ -48,7 +48,8 @@ "two_factor_code", "channel_verification", "passkey_recovery", - "legacy_login_alert" + "legacy_login_alert", + "migration_verification" ] } } diff --git a/.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json b/.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json deleted file mode 100644 index bdf5ff6..0000000 --- a/.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5" -} diff --git a/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json index d29765e..73a571f 100644 --- a/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json +++ b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json @@ -40,7 +40,8 @@ "two_factor_code", "channel_verification", "passkey_recovery", - "legacy_login_alert" + "legacy_login_alert", + "migration_verification" ] } } diff --git a/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json b/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json deleted file mode 100644 index e3a0c80..0000000 --- a/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json b/.sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json deleted file mode 100644 index 9244ba0..0000000 --- a/.sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - { - "Custom": { - "name": "comms_channel", - "kind": { - "Enum": [ - "email", - "discord", - "telegram", - "signal" - ] - } - } - } - ] - }, - "nullable": [] - }, - "hash": "57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e" -} diff --git a/.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json b/.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json deleted file mode 100644 index f597186..0000000 --- a/.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "code", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - { - "Custom": { - "name": "comms_channel", - "kind": { - "Enum": [ - "email", - "discord", - "telegram", - "signal" - ] - } - } - } - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1" -} diff --git a/.sqlx/query-5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082.json b/.sqlx/query-5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082.json new file mode 100644 index 0000000..af8bd06 --- /dev/null +++ b/.sqlx/query-5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, email, email_verified FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082" +} diff --git a/.sqlx/query-5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922.json b/.sqlx/query-5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922.json new file mode 100644 index 0000000..7b0a696 --- /dev/null +++ b/.sqlx/query-5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, did, email, email_verified, handle FROM users WHERE LOWER(email) = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "handle", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922" +} diff --git a/.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json b/.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json deleted file mode 100644 index 5b1d026..0000000 --- a/.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161" -} diff --git a/.sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json b/.sqlx/query-9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a.json similarity index 67% rename from .sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json rename to .sqlx/query-9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a.json index b98bfef..8b5e3b1 100644 --- a/.sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json +++ b/.sqlx/query-9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", + "query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n u.discord_id, u.telegram_username, u.signal_number,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", "describe": { "columns": [ { @@ -42,11 +42,26 @@ }, { "ordinal": 5, + "name": "discord_id", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "telegram_username", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "signal_number", + "type_info": "Text" + }, + { + "ordinal": 8, "name": "key_bytes", "type_info": "Bytea" }, { - "ordinal": 6, + "ordinal": 9, "name": "encryption_version", "type_info": "Int4" } @@ -62,9 +77,12 @@ false, true, false, + true, + true, + true, false, true ] }, - "hash": "efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a" + "hash": "9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a" } diff --git a/.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json b/.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json deleted file mode 100644 index d5fe56b..0000000 --- a/.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "code", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "pending_identifier", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - true, - false - ] - }, - "hash": "a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3" -} diff --git a/.sqlx/query-a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005.json b/.sqlx/query-a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005.json new file mode 100644 index 0000000..a5c2d6e --- /dev/null +++ b/.sqlx/query-a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005" +} diff --git a/.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json b/.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json deleted file mode 100644 index c82d660..0000000 --- a/.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2" -} diff --git a/.sqlx/query-b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a.json b/.sqlx/query-b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a.json new file mode 100644 index 0000000..116bfa7 --- /dev/null +++ b/.sqlx/query-b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email_verified = true WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a" +} diff --git a/.sqlx/query-c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6.json b/.sqlx/query-c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6.json new file mode 100644 index 0000000..9ae8c70 --- /dev/null +++ b/.sqlx/query-c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "discord_verified", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "telegram_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "signal_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6" +} diff --git a/.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json b/.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json deleted file mode 100644 index bba38eb..0000000 --- a/.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::comms_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - { - "Custom": { - "name": "comms_channel", - "kind": { - "Enum": [ - "email", - "discord", - "telegram", - "signal" - ] - } - } - }, - "Text", - "Text", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6" -} diff --git a/.sqlx/query-cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9.json b/.sqlx/query-cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9.json new file mode 100644 index 0000000..dd7d849 --- /dev/null +++ b/.sqlx/query-cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email_verified = TRUE WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9" +} diff --git a/.sqlx/query-d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87.json b/.sqlx/query-d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87.json new file mode 100644 index 0000000..628e860 --- /dev/null +++ b/.sqlx/query-d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET discord_verified = TRUE WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87" +} diff --git a/.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json b/.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json deleted file mode 100644 index c0b5eda..0000000 --- a/.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839" -} diff --git a/.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json b/.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json deleted file mode 100644 index 720cd39..0000000 --- a/.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::comms_channel\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "code", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "pending_identifier", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - { - "Custom": { - "name": "comms_channel", - "kind": { - "Enum": [ - "email", - "discord", - "telegram", - "signal" - ] - } - } - } - ] - }, - "nullable": [ - false, - true, - false - ] - }, - "hash": "f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5" -} diff --git a/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json index 2c6ef01..bd681a6 100644 --- a/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json +++ b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json @@ -43,7 +43,8 @@ "two_factor_code", "channel_verification", "passkey_recovery", - "legacy_login_alert" + "legacy_login_alert", + "migration_verification" ] } } diff --git a/frontend/deno.lock b/frontend/deno.lock index a3c05bf..1105a1c 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -8,6 +8,7 @@ "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1", "npm:jsdom@^25.0.1": "25.0.1", "npm:multiformats@^13.3.1": "13.4.2", + "npm:svelte-check@*": "4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3", "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0", "npm:svelte@5": "5.45.10_acorn@8.15.0", "npm:vite@*": "6.4.1_picomatch@4.0.3", @@ -794,6 +795,12 @@ "check-error@2.1.1": { "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==" }, + "chokidar@4.0.3": { + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": [ + "readdirp" + ] + }, "cli-color@2.0.4": { "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", "dependencies": [ @@ -1339,6 +1346,9 @@ "react-is@17.0.2": { "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "readdirp@4.1.2": { + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" + }, "redent@3.0.0": { "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dependencies": [ @@ -1417,6 +1427,19 @@ "min-indent" ] }, + "svelte-check@4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3": { + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dependencies": [ + "@jridgewell/trace-mapping", + "chokidar", + "fdir", + "picocolors", + "sade", + "svelte", + "typescript" + ], + "bin": true + }, "svelte-i18n@4.0.1_svelte@5.45.10__acorn@8.15.0": { "integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==", "dependencies": [ @@ -1518,6 +1541,10 @@ "type@2.7.3": { "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" }, + "typescript@5.9.3": { + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "bin": true + }, "vite-node@2.1.9": { "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", "dependencies": [ diff --git a/frontend/src/components/ui/Input.svelte b/frontend/src/components/ui/Input.svelte index 9c4bb3e..4049a71 100644 --- a/frontend/src/components/ui/Input.svelte +++ b/frontend/src/components/ui/Input.svelte @@ -15,7 +15,8 @@ ...rest }: Props = $props() - let inputId = id || `input-${Math.random().toString(36).slice(2, 9)}` + const fallbackId = `input-${Math.random().toString(36).slice(2, 9)}` + let inputId = $derived(id || fallbackId)
diff --git a/frontend/src/components/ui/Section.svelte b/frontend/src/components/ui/Section.svelte index fad9878..205de4c 100644 --- a/frontend/src/components/ui/Section.svelte +++ b/frontend/src/components/ui/Section.svelte @@ -33,10 +33,6 @@ padding: var(--space-6); } - .section + .section { - margin-top: var(--space-6); - } - .section-danger { background: var(--error-bg); border: 1px solid var(--error-border); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7fafcee..3148d76 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -319,11 +319,11 @@ export const api = { }) }, - async confirmChannelVerification(token: string, channel: string, code: string): Promise<{ success: boolean }> { + async confirmChannelVerification(token: string, channel: string, identifier: string, code: string): Promise<{ success: boolean }> { return xrpc('com.tranquil.account.confirmChannelVerification', { method: 'POST', token, - body: { channel, code }, + body: { channel, identifier, code }, }) }, @@ -854,4 +854,31 @@ export const api = { body: { did, recoveryToken, newPassword }, }) }, + + async verifyMigrationEmail(token: string, email: string): Promise<{ success: boolean; did: string }> { + return xrpc('com.atproto.server.verifyMigrationEmail', { + method: 'POST', + body: { token, email }, + }) + }, + + async resendMigrationVerification(email: string): Promise<{ sent: boolean }> { + return xrpc('com.atproto.server.resendMigrationVerification', { + method: 'POST', + body: { email }, + }) + }, + + async verifyToken(token: string, identifier: string, accessToken?: string): Promise<{ + success: boolean + did: string + purpose: string + channel: string + }> { + return xrpc('com.tranquil.account.verifyToken', { + method: 'POST', + body: { token, identifier }, + token: accessToken, + }) + }, } diff --git a/frontend/src/lib/registration/VerificationStep.svelte b/frontend/src/lib/registration/VerificationStep.svelte index cde1afb..84981b3 100644 --- a/frontend/src/lib/registration/VerificationStep.svelte +++ b/frontend/src/lib/registration/VerificationStep.svelte @@ -70,13 +70,13 @@ id="verification-code" type="text" bind:value={verificationCode} - placeholder="Enter 6-digit code" + placeholder="XXXX-XXXX-XXXX-XXXX" disabled={flow.state.submitting} required - maxlength="6" - inputmode="numeric" autocomplete="one-time-code" + class="code-input" /> + Copy the entire code from your message, including dashes.
+ {$_('admin.logoPreview')} + {:else} {/if} - Used as favicon and shown in the navbar + {$_('admin.logoHelp')} -

Theme Colors

-

Leave blank to use default colors.

+

{$_('admin.themeColors')}

+

{$_('admin.themeColorsHint')}

- +
- +
- +
- +
@@ -426,48 +426,48 @@
{serverConfigError}
{/if} {#if serverConfigSuccess} -
Server configuration saved
+
{$_('admin.configSaved')}
{/if} {#if stats}
-

Server Statistics

+

{$_('admin.serverStats')}

{formatNumber(stats.userCount)}
-
Users
+
{$_('admin.users')}
{formatNumber(stats.repoCount)}
-
Repositories
+
{$_('admin.repos')}
{formatNumber(stats.recordCount)}
-
Records
+
{$_('admin.records')}
{formatBytes(stats.blobStorageBytes)}
-
Blob Storage
+
{$_('admin.blobStorage')}
- +
{/if}
-

User Management

+

{$_('admin.userManagement')}

{#if usersError} @@ -476,15 +476,15 @@ {#if showUsers}
{#if users.length === 0} -

No users found

+

{$_('admin.noUsers')}

{:else} - - - - + + + + @@ -494,11 +494,11 @@ @@ -508,7 +508,7 @@
HandleEmailStatusCreated{$_('admin.handle')}{$_('admin.email')}{$_('admin.status')}{$_('admin.created')}
{#if user.deactivatedAt} - Deactivated + {$_('admin.deactivated')} {:else if user.emailConfirmedAt} - Verified + {$_('admin.verified')} {:else} - Unverified + {$_('admin.unverified')} {/if} {formatDate(user.indexedAt)}
{#if usersCursor} {/if} {/if} @@ -516,10 +516,10 @@ {/if}
-

Invite Codes

+

{$_('admin.inviteCodes')}

{#if invitesError} @@ -528,17 +528,17 @@ {#if showInvites}
{#if invites.length === 0} -

No invite codes found

+

{$_('admin.noInvites')}

{:else} - - - - - - + + + + + + @@ -549,18 +549,18 @@
CodeAvailableUsesStatusCreatedActions{$_('admin.code')}{$_('admin.available')}{$_('admin.uses')}{$_('admin.status')}{$_('admin.created')}{$_('admin.actions')}
{invite.uses.length} {#if invite.disabled} - Disabled + {$_('admin.disabled')} {:else if invite.available === 0} - Exhausted + {$_('admin.exhausted')} {:else} - Active + {$_('admin.active')} {/if} {formatDate(invite.createdAt)} {#if !invite.disabled} {:else} - @@ -572,7 +572,7 @@
{#if invitesCursor} {/if} {/if} @@ -585,38 +585,38 @@ {/if} {:else if auth.loading} -
Loading...
+
{$_('admin.loading')}
{/if} diff --git a/migrations/20251232_add_migration_verification_comms_type.sql b/migrations/20251232_add_migration_verification_comms_type.sql new file mode 100644 index 0000000..6cf8a9d --- /dev/null +++ b/migrations/20251232_add_migration_verification_comms_type.sql @@ -0,0 +1 @@ +ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'migration_verification'; diff --git a/migrations/20251233_remove_channel_verifications.sql b/migrations/20251233_remove_channel_verifications.sql new file mode 100644 index 0000000..37e780b --- /dev/null +++ b/migrations/20251233_remove_channel_verifications.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS channel_verifications; diff --git a/src/api/admin/config.rs b/src/api/admin/config.rs index 506da8a..4e23f64 100644 --- a/src/api/admin/config.rs +++ b/src/api/admin/config.rs @@ -1,7 +1,7 @@ use crate::api::error::ApiError; use crate::auth::BearerAuthAdmin; use crate::state::AppState; -use axum::{extract::State, Json}; +use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use tracing::error; @@ -80,7 +80,7 @@ pub async fn get_server_config( async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> { sqlx::query( "INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW()) - ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()" + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()", ) .bind(key) .bind(value) @@ -105,7 +105,9 @@ pub async fn update_server_config( if let Some(server_name) = req.server_name { let trimmed = server_name.trim(); if trimmed.is_empty() || trimmed.len() > 100 { - return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into())); + return Err(ApiError::InvalidRequest( + "Server name must be 1-100 characters".into(), + )); } upsert_config(&state.db, "server_name", trimmed).await?; } @@ -116,7 +118,9 @@ pub async fn update_server_config( } else if is_valid_hex_color(color) { upsert_config(&state.db, "primary_color", color).await?; } else { - return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into())); + return Err(ApiError::InvalidRequest( + "Invalid primary color format (expected #RRGGBB)".into(), + )); } } @@ -126,7 +130,9 @@ pub async fn update_server_config( } else if is_valid_hex_color(color) { upsert_config(&state.db, "primary_color_dark", color).await?; } else { - return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into())); + return Err(ApiError::InvalidRequest( + "Invalid primary dark color format (expected #RRGGBB)".into(), + )); } } @@ -136,7 +142,9 @@ pub async fn update_server_config( } else if is_valid_hex_color(color) { upsert_config(&state.db, "secondary_color", color).await?; } else { - return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into())); + return Err(ApiError::InvalidRequest( + "Invalid secondary color format (expected #RRGGBB)".into(), + )); } } @@ -146,16 +154,17 @@ pub async fn update_server_config( } else if is_valid_hex_color(color) { upsert_config(&state.db, "secondary_color_dark", color).await?; } else { - return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into())); + return Err(ApiError::InvalidRequest( + "Invalid secondary dark color format (expected #RRGGBB)".into(), + )); } } if let Some(ref logo_cid) = req.logo_cid { - let old_logo_cid: Option = sqlx::query_scalar( - "SELECT value FROM server_config WHERE key = 'logo_cid'" - ) - .fetch_optional(&state.db) - .await?; + let old_logo_cid: Option = + sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'") + .fetch_optional(&state.db) + .await?; let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) { (Some(old), true) => Some(old.clone()), @@ -163,23 +172,20 @@ pub async fn update_server_config( _ => None, }; - if let Some(old_cid) = should_delete_old { - if let Ok(Some(blob)) = sqlx::query!( - "SELECT storage_key FROM blobs WHERE cid = $1", - old_cid - ) - .fetch_optional(&state.db) - .await - { - if let Err(e) = state.blob_store.delete(&blob.storage_key).await { - error!("Failed to delete old logo blob from storage: {:?}", e); - } - if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid) - .execute(&state.db) + if let Some(old_cid) = should_delete_old + && let Ok(Some(blob)) = + sqlx::query!("SELECT storage_key FROM blobs WHERE cid = $1", old_cid) + .fetch_optional(&state.db) .await - { - error!("Failed to delete old logo blob record: {:?}", e); - } + { + if let Err(e) = state.blob_store.delete(&blob.storage_key).await { + error!("Failed to delete old logo blob from storage: {:?}", e); + } + if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid) + .execute(&state.db) + .await + { + error!("Failed to delete old logo blob record: {:?}", e); } } diff --git a/src/api/error.rs b/src/api/error.rs index 562b485..5e41fda 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -94,7 +94,9 @@ impl ApiError { fn error_name(&self) -> Cow<'static, str> { match self { Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"), - Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"), + Self::UpstreamFailure | Self::UpstreamUnavailable(_) => { + Cow::Borrowed("UpstreamFailure") + } Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"), Self::UpstreamError { error, .. } => { if let Some(e) = error { diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 3e967ea..4e2aa04 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -132,11 +132,11 @@ pub async fn create_account( .map(|d| d.starts_with("did:plc:")) .unwrap_or(false); - if is_migration || is_did_web_byod { - if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) - { - if provided_did != auth_did { - return ( + if (is_migration || is_did_web_byod) + && let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) + { + if provided_did != auth_did { + return ( StatusCode::FORBIDDEN, Json(json!({ "error": "AuthorizationError", @@ -144,12 +144,11 @@ pub async fn create_account( })), ) .into_response(); - } - if is_did_web_byod { - info!(did = %provided_did, "Processing did:web BYOD account creation"); - } else { - info!(did = %provided_did, "Processing account migration"); - } + } + if is_did_web_byod { + info!(did = %provided_did, "Processing did:web BYOD account creation"); + } else { + info!(did = %provided_did, "Processing account migration"); } } @@ -348,16 +347,15 @@ pub async fn create_account( ) .into_response(); } - if !is_did_web_byod { - if let Err(e) = + if !is_did_web_byod + && 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})), - ) - .into_response(); - } + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidDid", "message": e})), + ) + .into_response(); } info!(did = %d, "Creating external did:web account"); d.clone() @@ -368,17 +366,20 @@ pub async fn create_account( info!(did = %d, "Migration with existing did:plc"); d.clone() } else if d.starts_with("did:web:") { - if !is_did_web_byod { - 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})), - ) - .into_response(); - } + if !is_did_web_byod + && 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})), + ) + .into_response(); } d.clone() } else if !d.trim().is_empty() { @@ -710,8 +711,6 @@ pub async fn create_account( .into_response(); } }; - let verification_code = format!("{:06}", rand::random::() % 1_000_000); - let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30); let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") .fetch_one(&mut *tx) .await @@ -758,7 +757,7 @@ pub async fn create_account( ) .bind(is_first_user) .bind(deactivated_at) - .bind(is_migration) + .bind(false) .fetch_one(&mut *tx) .await; let user_id = match user_insert { @@ -806,25 +805,6 @@ pub async fn create_account( } }; - if !is_migration - && 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 - ) - .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) => { @@ -881,17 +861,18 @@ pub async fn create_account( } }; let rev = Tid::now(LimitedU32::MIN); - let (commit_bytes, _sig) = match create_signed_commit(&did, mst_root, &rev.to_string(), None, &signing_key) { - Ok(result) => result, - Err(e) => { - error!("Error creating genesis commit: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; + let (commit_bytes, _sig) = + match create_signed_commit(&did, mst_root, rev.as_ref(), None, &signing_key) { + Ok(result) => result, + Err(e) => { + error!("Error creating genesis commit: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; let commit_cid = match state.block_store.put(&commit_bytes).await { Ok(c) => c, Err(e) => { @@ -973,22 +954,45 @@ pub async fn create_account( warn!("Failed to create default profile for {}: {}", did, e); } } + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if !is_migration { - if let Some(ref recipient) = verification_recipient - && let Err(e) = crate::comms::enqueue_signup_verification( + if let Some(ref recipient) = verification_recipient { + let verification_token = crate::auth::verification_token::generate_signup_token( + &did, + verification_channel, + recipient, + ); + let formatted_token = + crate::auth::verification_token::format_token_for_display(&verification_token); + if let Err(e) = crate::comms::enqueue_signup_verification( &state.db, user_id, verification_channel, recipient, - &verification_code, + &formatted_token, None, ) .await + { + warn!( + "Failed to enqueue signup verification notification: {:?}", + e + ); + } + } + } else if let Some(ref user_email) = email { + let token = crate::auth::verification_token::generate_migration_token(&did, user_email); + let formatted_token = crate::auth::verification_token::format_token_for_display(&token); + if let Err(e) = crate::comms::enqueue_migration_verification( + &state.db, + user_id, + user_email, + &formatted_token, + &hostname, + ) + .await { - warn!( - "Failed to enqueue signup verification notification: {:?}", - e - ); + warn!("Failed to enqueue migration verification email: {:?}", e); } } diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs index 8c7fca0..18312ea 100644 --- a/src/api/notification_prefs.rs +++ b/src/api/notification_prefs.rs @@ -6,21 +6,11 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use chrono::{Duration, Utc}; -use rand::Rng; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::Row; use tracing::info; -fn generate_verification_code() -> String { - rand::thread_rng() - .sample_iter(&rand::distributions::Uniform::new(0, 10)) - .take(6) - .map(|x| x.to_string()) - .collect() -} - #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct NotificationPrefsResponse { @@ -228,36 +218,28 @@ pub struct UpdateNotificationPrefsResponse { pub async fn request_channel_verification( db: &sqlx::PgPool, user_id: uuid::Uuid, + did: &str, channel: &str, identifier: &str, handle: Option<&str>, ) -> Result { - let code = generate_verification_code(); - let expires_at = Utc::now() + Duration::minutes(10); - - sqlx::query!( - r#" - INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) - VALUES ($1, $2::comms_channel, $3, $4, $5) - ON CONFLICT (user_id, channel) DO UPDATE - SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW() - "#, - user_id, - channel as _, - code, - identifier, - expires_at - ) - .execute(db) - .await - .map_err(|e| format!("Database error: {}", e))?; + let token = + crate::auth::verification_token::generate_channel_update_token(did, channel, identifier); + let formatted_token = crate::auth::verification_token::format_token_for_display(&token); if channel == "email" { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle_str = handle.unwrap_or("user"); - crate::comms::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname) - .await - .map_err(|e| format!("Failed to enqueue email notification: {}", e))?; + crate::comms::enqueue_email_update( + db, + user_id, + identifier, + handle_str, + &formatted_token, + &hostname, + ) + .await + .map_err(|e| format!("Failed to enqueue email notification: {}", e))?; } else { sqlx::query!( r#" @@ -267,15 +249,15 @@ pub async fn request_channel_verification( user_id, channel as _, identifier, - format!("Your verification code is: {}", code), - json!({"code": code}) + format!("Your verification code is: {}", formatted_token), + json!({"code": formatted_token}) ) .execute(db) .await .map_err(|e| format!("Failed to enqueue notification: {}", e))?; } - Ok(code) + Ok(token) } pub async fn update_notification_prefs( @@ -397,6 +379,7 @@ pub async fn update_notification_prefs( if let Err(e) = request_channel_verification( &state.db, user_id, + &user.did, "email", &email_clean, Some(&handle), @@ -429,16 +412,12 @@ pub async fn update_notification_prefs( ) .into_response(); } - let _ = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", - user_id - ) - .execute(&state.db) - .await; info!(did = %user.did, "Cleared Discord ID"); } else { - if let Err(e) = - request_channel_verification(&state.db, user_id, "discord", discord_id, None).await + if let Err(e) = request_channel_verification( + &state.db, user_id, &user.did, "discord", discord_id, None, + ) + .await { return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -467,17 +446,17 @@ pub async fn update_notification_prefs( ) .into_response(); } - let _ = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'", - user_id - ) - .execute(&state.db) - .await; info!(did = %user.did, "Cleared Telegram username"); } else { - if let Err(e) = - request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None) - .await + if let Err(e) = request_channel_verification( + &state.db, + user_id, + &user.did, + "telegram", + telegram_clean, + None, + ) + .await { return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -505,16 +484,11 @@ pub async fn update_notification_prefs( ) .into_response(); } - let _ = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'", - user_id - ) - .execute(&state.db) - .await; info!(did = %user.did, "Cleared Signal number"); } else { if let Err(e) = - request_channel_verification(&state.db, user_id, "signal", signal, None).await + request_channel_verification(&state.db, user_id, &user.did, "signal", signal, None) + .await { return ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs index 4359c53..acbb766 100644 --- a/src/api/repo/record/utils.rs +++ b/src/api/repo/record/utils.rs @@ -3,7 +3,7 @@ use bytes::Bytes; use cid::Cid; use jacquard::types::{integer::LimitedU32, string::Tid}; use jacquard_repo::storage::BlockStore; -use k256::ecdsa::{signature::Signer, Signature, SigningKey}; +use k256::ecdsa::{Signature, SigningKey, signature::Signer}; use serde::Serialize; use serde_json::json; use uuid::Uuid; diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 6c1d582..95e5a10 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -6,7 +6,6 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use chrono::Utc; use serde::Deserialize; use serde_json::json; use tracing::{error, info, warn}; @@ -66,7 +65,7 @@ pub async fn request_email_update( return e; } - let did = auth_user.did; + let did = auth_user.did.clone(); let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await @@ -117,6 +116,7 @@ pub async fn request_email_update( if let Err(e) = crate::api::notification_prefs::request_channel_verification( &state.db, user_id, + &did, "email", &email, Some(&handle), @@ -206,62 +206,50 @@ pub async fn confirm_email( } }; - let verification = match sqlx::query!( - "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", - user_id - ) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => row, - _ => { + let email = input.email.trim().to_lowercase(); + let confirmation_code = + crate::auth::verification_token::normalize_token_input(input.token.trim()); + + let verified = crate::auth::verification_token::verify_channel_update_token( + &confirmation_code, + "email", + &email, + ); + + match verified { + Ok(token_data) => { + if token_data.did != did { + return ( + StatusCode::BAD_REQUEST, + Json( + json!({"error": "InvalidToken", "message": "Token does not match account"}), + ), + ) + .into_response(); + } + } + Err(crate::auth::verification_token::VerifyError::Expired) => { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), + Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidToken", "message": "Invalid token"})), ) .into_response(); } - }; - - let pending_email = verification.pending_identifier.unwrap_or_default(); - let email = input.email.trim().to_lowercase(); - let confirmation_code = input.token.trim(); - - if pending_email != email { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})), - ) - .into_response(); } - if verification.code != confirmation_code { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Invalid token"})), - ) - .into_response(); - } - - if Utc::now() > verification.expires_at { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), - ) - .into_response(); - } - - let mut tx = match state.db.begin().await { - Ok(tx) => tx, - Err(_) => return ApiError::InternalError.into_response(), - }; - let update = sqlx::query!( - "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", - pending_email, + "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", + email, user_id ) - .execute(&mut *tx) + .execute(&state.db) .await; if let Err(e) = update { @@ -283,21 +271,6 @@ pub async fn confirm_email( .into_response(); } - if let Err(e) = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", - user_id - ) - .execute(&mut *tx) - .await - { - error!("Failed to delete verification record: {:?}", e); - return ApiError::InternalError.into_response(); - } - - if tx.commit().await.is_err() { - return ApiError::InternalError.into_response(); - } - info!("Email updated for user {}", user_id); (StatusCode::OK, Json(json!({}))).into_response() } @@ -377,50 +350,49 @@ pub async fn update_email( return (StatusCode::OK, Json(json!({}))).into_response(); } - let verification = sqlx::query!( - "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", - user_id - ) - .fetch_optional(&state.db) - .await - .unwrap_or(None); + let confirmation_token = match &input.token { + Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()), + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})), + ) + .into_response(); + } + }; - if let Some(ver) = verification { - let confirmation_token = match &input.token { - Some(t) => t.trim(), - None => { + let verified = crate::auth::verification_token::verify_channel_update_token( + &confirmation_token, + "email", + &new_email, + ); + + match verified { + Ok(token_data) => { + if token_data.did != did { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})), + Json( + json!({"error": "InvalidToken", "message": "Token does not match account"}), + ), ) .into_response(); } - }; - - let pending_email = ver.pending_identifier.unwrap_or_default(); - if pending_email.to_lowercase() != new_email { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})), - ) - .into_response(); } - - if ver.code != confirmation_token { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Invalid token"})), - ) - .into_response(); - } - - if Utc::now() > ver.expires_at { + Err(crate::auth::verification_token::VerifyError::Expired) => { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), ) .into_response(); } + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidToken", "message": "Invalid token"})), + ) + .into_response(); + } } let exists = sqlx::query!( @@ -439,17 +411,12 @@ pub async fn update_email( .into_response(); } - let mut tx = match state.db.begin().await { - Ok(tx) => tx, - Err(_) => return ApiError::InternalError.into_response(), - }; - let update = sqlx::query!( - "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", + "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", new_email, user_id ) - .execute(&mut *tx) + .execute(&state.db) .await; if let Err(e) = update { @@ -471,17 +438,6 @@ pub async fn update_email( .into_response(); } - let _ = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", - user_id - ) - .execute(&mut *tx) - .await; - - if tx.commit().await.is_err() { - return ApiError::InternalError.into_response(); - } - match sqlx::query!( "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2", user_id, diff --git a/src/api/server/logo.rs b/src/api/server/logo.rs index c8c42b9..91b02b4 100644 --- a/src/api/server/logo.rs +++ b/src/api/server/logo.rs @@ -9,18 +9,17 @@ use axum::{ use tracing::error; pub async fn get_logo(State(state): State) -> Response { - let logo_cid: Option = match sqlx::query_scalar( - "SELECT value FROM server_config WHERE key = 'logo_cid'" - ) - .fetch_optional(&state.db) - .await - { - Ok(cid) => cid, - Err(e) => { - error!("DB error fetching logo_cid: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; + let logo_cid: Option = + match sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'") + .fetch_optional(&state.db) + .await + { + Ok(cid) => cid, + Err(e) => { + error!("DB error fetching logo_cid: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; let cid = match logo_cid { Some(c) if !c.is_empty() => c, diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index aceb14d..588113c 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -13,6 +13,8 @@ pub mod session; pub mod signing_key; pub mod totp; pub mod trusted_devices; +pub mod verify_email; +pub mod verify_token; pub use account_status::{ activate_account, check_account_status, deactivate_account, delete_account, @@ -35,9 +37,9 @@ pub use password::{ change_password, get_password_status, remove_password, request_password_reset, reset_password, }; pub use reauth::{ - check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response, - reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response, - reauth_totp, update_mfa_verified, + check_legacy_session_mfa, check_reauth_required, get_reauth_status, + legacy_mfa_required_response, reauth_passkey_finish, reauth_passkey_start, reauth_password, + reauth_required_response, reauth_totp, update_mfa_verified, }; pub use service_auth::get_service_auth; pub use session::{ @@ -54,3 +56,5 @@ pub use trusted_devices::{ extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device, trust_device, update_trusted_device, }; +pub use verify_email::{resend_migration_verification, verify_migration_email}; +pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal}; diff --git a/src/api/server/passkey_account.rs b/src/api/server/passkey_account.rs index 79c6fd9..9b5350b 100644 --- a/src/api/server/passkey_account.rs +++ b/src/api/server/passkey_account.rs @@ -117,7 +117,10 @@ pub async fn create_passkey_account( .await { Ok(claims) => { - debug!("Service token verified for BYOD did:web: iss={}", claims.iss); + debug!( + "Service token verified for BYOD did:web: iss={}", + claims.iss + ); Some(claims.iss) } Err(e) => { @@ -342,9 +345,10 @@ pub async fn create_passkey_account( .into_response(); } if is_byod_did_web { - if let Some(ref auth_did) = byod_auth { - if d != auth_did { - return ( + if let Some(ref auth_did) = byod_auth + && d != auth_did + { + return ( StatusCode::FORBIDDEN, Json(json!({ "error": "AuthorizationError", @@ -352,7 +356,6 @@ pub async fn create_passkey_account( })), ) .into_response(); - } } info!(did = %d, "Creating external did:web passkey account (BYOD key)"); } else { @@ -416,12 +419,6 @@ pub async fn create_passkey_account( 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, @@ -591,17 +588,18 @@ pub async fn create_passkey_account( } }; let rev = Tid::now(LimitedU32::MIN); - let (commit_bytes, _sig) = match create_signed_commit(&did, mst_root, &rev.to_string(), None, &secret_key) { - Ok(result) => result, - Err(e) => { - error!("Error creating genesis commit: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; + let (commit_bytes, _sig) = + match create_signed_commit(&did, mst_root, rev.as_ref(), None, &secret_key) { + Ok(result) => result, + Err(e) => { + error!("Error creating 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) => { @@ -647,25 +645,6 @@ pub async fn create_passkey_account( .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 ( @@ -703,12 +682,19 @@ pub async fn create_passkey_account( } } + let verification_token = crate::auth::verification_token::generate_signup_token( + &did, + verification_channel, + &verification_recipient, + ); + let formatted_token = + crate::auth::verification_token::format_token_for_display(&verification_token); if let Err(e) = crate::comms::enqueue_signup_verification( &state.db, user_id, verification_channel, &verification_recipient, - &verification_code, + &formatted_token, None, ) .await @@ -847,21 +833,20 @@ pub async fn complete_passkey_setup( } }; - 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 ( + 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, diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 642e2de..2e0d0ae 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -471,7 +471,13 @@ pub async fn remove_password(State(state): State, auth: BearerAuth) -> .await; } - if crate::api::server::reauth::check_reauth_required_cached(&state.db, &state.cache, &auth.0.did).await { + if crate::api::server::reauth::check_reauth_required_cached( + &state.db, + &state.cache, + &auth.0.did, + ) + .await + { return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; } diff --git a/src/api/server/reauth.rs b/src/api/server/reauth.rs index 76e2ea7..cba46c1 100644 --- a/src/api/server/reauth.rs +++ b/src/api/server/reauth.rs @@ -376,7 +376,8 @@ pub async fn reauth_passkey_finish( { Ok(false) => { warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key"); - let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; + let _ = + crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; return ( StatusCode::UNAUTHORIZED, Json(json!({ @@ -494,14 +495,14 @@ pub async fn check_reauth_required_cached( did: &str, ) -> bool { let cache_key = format!("reauth:{}", did); - if let Some(timestamp_str) = cache.get(&cache_key).await { - if let Ok(timestamp) = timestamp_str.parse::() { - let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0); - if let Some(t) = reauth_time { - let elapsed = Utc::now().signed_duration_since(t); - if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { - return false; - } + if let Some(timestamp_str) = cache.get(&cache_key).await + && let Ok(timestamp) = timestamp_str.parse::() + { + let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0); + if let Some(t) = reauth_time { + let elapsed = Utc::now().signed_duration_since(t); + if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { + return false; } } } diff --git a/src/api/server/service_auth.rs b/src/api/server/service_auth.rs index f0906cc..191c28d 100644 --- a/src/api/server/service_auth.rs +++ b/src/api/server/service_auth.rs @@ -66,7 +66,9 @@ pub async fn get_service_auth( } }; - let (token, is_dpop) = if auth_header.len() >= 7 && auth_header[..7].eq_ignore_ascii_case("bearer ") { + let (token, is_dpop) = if auth_header.len() >= 7 + && auth_header[..7].eq_ignore_ascii_case("bearer ") + { (auth_header[7..].trim().to_string(), false) } else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") { (auth_header[5..].trim().to_string(), true) @@ -81,10 +83,14 @@ pub async fn get_service_auth( &token, dpop_proof, "GET", - &format!("/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}", - params.aud, - params.lxm.as_deref().unwrap_or("")), - ).await { + &format!( + "/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}", + params.aud, + params.lxm.as_deref().unwrap_or("") + ), + ) + .await + { Ok(result) => crate::auth::AuthenticatedUser { did: result.did, is_oauth: true, @@ -100,7 +106,8 @@ pub async fn get_service_auth( "error": "use_dpop_nonce", "message": "DPoP nonce required" })), - ).into_response(); + ) + .into_response(); } Err(e) => { warn!(error = ?e, "getServiceAuth DPoP auth validation failed"); @@ -110,7 +117,8 @@ pub async fn get_service_auth( "error": "AuthenticationFailed", "message": format!("{:?}", e) })), - ).into_response(); + ) + .into_response(); } } } else { @@ -136,7 +144,7 @@ pub async fn get_service_auth( "SELECT k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id - WHERE u.did = $1" + WHERE u.did = $1", ) .bind(&auth_user.did) .fetch_optional(&state.db) @@ -155,17 +163,13 @@ pub async fn get_service_auth( } } Ok(None) => { - return ApiError::AuthenticationFailedMsg( - "User has no signing key".into(), - ) - .into_response(); + return ApiError::AuthenticationFailedMsg("User has no signing key".into()) + .into_response(); } Err(e) => { error!(error = ?e, "DB error fetching user key"); - return ApiError::AuthenticationFailedMsg( - "Failed to get signing key".into(), - ) - .into_response(); + return ApiError::AuthenticationFailedMsg("Failed to get signing key".into()) + .into_response(); } } } diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 652b7a8..638feeb 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -8,7 +8,6 @@ use axum::{ response::{IntoResponse, Response}, }; use bcrypt::verify; -use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, info, warn}; @@ -167,10 +166,7 @@ pub async fn create_session( let has_totp = row.totp_enabled.unwrap_or(false); let is_legacy_login = has_totp; if has_totp && !row.allow_legacy_login { - warn!( - "Legacy login blocked for TOTP-enabled account: {}", - row.did - ); + warn!("Legacy login blocked for TOTP-enabled account: {}", row.did); return ( StatusCode::FORBIDDEN, Json(json!({ @@ -556,6 +552,7 @@ pub async fn confirm_signup( r#"SELECT u.id, u.did, u.handle, u.email, u.preferred_comms_channel as "channel: crate::comms::CommsChannel", + u.discord_id, u.telegram_username, u.signal_number, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id @@ -577,38 +574,46 @@ 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 = $2::comms_channel", - row.id, - channel_str as _ - ) - .fetch_optional(&state.db) - .await - { - Ok(Some(v)) => v, - Ok(None) => { - warn!("No verification code found for user: {}", input.did); - return ApiError::InvalidRequest("No pending verification".into()).into_response(); + let (channel_str, identifier) = 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()) } - Err(e) => { - error!("Database error fetching verification: {:?}", e); - return ApiError::InternalError.into_response(); + 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 verification.code != input.verification_code { - warn!("Invalid verification code for user: {}", input.did); - return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); - } - if verification.expires_at < Utc::now() { - warn!("Verification code expired for user: {}", input.did); - return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response(); + let normalized_token = + crate::auth::verification_token::normalize_token_input(&input.verification_code); + match crate::auth::verification_token::verify_signup_token( + &normalized_token, + channel_str, + &identifier, + ) { + Ok(token_data) => { + if token_data.did != input.did { + warn!( + "Token DID mismatch for confirm_signup: expected {}, got {}", + input.did, token_data.did + ); + return ApiError::InvalidRequest("Invalid verification code".into()) + .into_response(); + } + } + Err(crate::auth::verification_token::VerifyError::Expired) => { + warn!("Verification code expired for user: {}", input.did); + return ApiError::ExpiredTokenMsg("Verification code has expired".into()) + .into_response(); + } + Err(e) => { + warn!("Invalid verification code for user {}: {:?}", input.did, e); + return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); + } } let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { @@ -634,17 +639,6 @@ pub async fn confirm_signup( return ApiError::InternalError.into_response(); } - if let Err(e) = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", - row.id, - channel_str as _ - ) - .execute(&state.db) - .await - { - error!("Failed to delete verification record: {:?}", e); - } - let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, Err(e) => { @@ -737,8 +731,6 @@ pub async fn resend_verification( if is_verified { return ApiError::InvalidRequest("Account is already verified".into()).into_response(); } - let verification_code = format!("{:06}", rand::random::() % 1_000_000); - let code_expires_at = Utc::now() + chrono::Duration::minutes(30); let (channel_str, recipient) = match row.channel { crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), @@ -754,31 +746,17 @@ pub async fn resend_verification( } }; - if let Err(e) = sqlx::query!( - r#" - INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) - VALUES ($1, $2::comms_channel, $3, $4, $5) - ON CONFLICT (user_id, channel) DO UPDATE - SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW() - "#, - row.id, - channel_str as _, - verification_code, - recipient, - code_expires_at - ) - .execute(&state.db) - .await - { - error!("Failed to update verification code: {:?}", e); - return ApiError::InternalError.into_response(); - } + let verification_token = + crate::auth::verification_token::generate_signup_token(&input.did, channel_str, &recipient); + let formatted_token = + crate::auth::verification_token::format_token_for_display(&verification_token); + if let Err(e) = crate::comms::enqueue_signup_verification( &state.db, row.id, channel_str, &recipient, - &verification_code, + &formatted_token, None, ) .await @@ -886,8 +864,7 @@ pub async fn list_sessions( Ok(rows) => { for (id, token_id, created_at, expires_at, client_id) in rows { let client_name = extract_client_name(&client_id); - let is_current_oauth = auth.0.is_oauth - && current_jti.as_ref() == Some(&token_id); + let is_current_oauth = auth.0.is_oauth && current_jti.as_ref() == Some(&token_id); sessions.push(SessionInfo { id: format!("oauth:{}", id), session_type: "oauth".to_string(), @@ -1071,11 +1048,12 @@ pub async fn revoke_all_sessions( .into_response(); } } else { - if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") - .bind(&auth.0.did) - .bind(jti) - .execute(&state.db) - .await + if let Err(e) = + sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") + .bind(&auth.0.did) + .bind(jti) + .execute(&state.db) + .await { error!("DB error revoking JWT sessions: {:?}", e); return ( diff --git a/src/api/server/verify_email.rs b/src/api/server/verify_email.rs new file mode 100644 index 0000000..438a279 --- /dev/null +++ b/src/api/server/verify_email.rs @@ -0,0 +1,101 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{info, warn}; + +use crate::state::AppState; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyMigrationEmailInput { + pub token: String, + pub email: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyMigrationEmailOutput { + pub success: bool, + pub did: String, +} + +pub async fn verify_migration_email( + State(state): State, + Json(input): Json, +) -> Result, (StatusCode, Json)> { + let token_input = super::verify_token::VerifyTokenInput { + token: input.token, + identifier: input.email, + }; + + let result = super::verify_token::verify_token_internal(&state, None, token_input).await?; + + Ok(Json(VerifyMigrationEmailOutput { + success: result.success, + did: result.did.clone(), + })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResendMigrationVerificationInput { + pub email: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResendMigrationVerificationOutput { + pub sent: bool, +} + +pub async fn resend_migration_verification( + State(state): State, + Json(input): Json, +) -> Result, (StatusCode, Json)> { + let email = input.email.trim().to_lowercase(); + + let user = sqlx::query!( + "SELECT id, did, email, email_verified, handle FROM users WHERE LOWER(email) = $1", + email + ) + .fetch_optional(&state.db) + .await + .map_err(|e| { + warn!(error = %e, "Database error during resend verification"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "InternalError", "message": "Database error" })), + ) + })?; + + let user = match user { + Some(u) => u, + None => { + return Ok(Json(ResendMigrationVerificationOutput { sent: true })); + } + }; + + if user.email_verified { + return Ok(Json(ResendMigrationVerificationOutput { sent: true })); + } + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let token = crate::auth::verification_token::generate_migration_token(&user.did, &email); + let formatted_token = crate::auth::verification_token::format_token_for_display(&token); + + if let Err(e) = crate::comms::enqueue_migration_verification( + &state.db, + user.id, + &email, + &formatted_token, + &hostname, + ) + .await + { + warn!(error = %e, "Failed to enqueue migration verification email"); + } + + info!(did = %user.did, "Resent migration verification email"); + + Ok(Json(ResendMigrationVerificationOutput { sent: true })) +} diff --git a/src/api/server/verify_token.rs b/src/api/server/verify_token.rs new file mode 100644 index 0000000..60509c4 --- /dev/null +++ b/src/api/server/verify_token.rs @@ -0,0 +1,391 @@ +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{error, info, warn}; + +use crate::auth::verification_token::{ + VerificationPurpose, VerifyError, normalize_token_input, verify_token_signature, +}; +use crate::state::AppState; + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VerifyTokenInput { + pub token: String, + pub identifier: String, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VerifyTokenOutput { + pub success: bool, + pub did: String, + pub purpose: String, + pub channel: String, +} + +pub async fn verify_token( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Result, (StatusCode, Json)> { + verify_token_internal(&state, Some(&headers), input).await +} + +pub async fn verify_token_internal( + state: &AppState, + headers: Option<&HeaderMap>, + input: VerifyTokenInput, +) -> Result, (StatusCode, Json)> { + let normalized_token = normalize_token_input(&input.token); + let identifier = input.identifier.trim().to_lowercase(); + + let token_data = match verify_token_signature(&normalized_token) { + Ok(data) => data, + Err(e) => { + let (status, error, message) = match e { + VerifyError::InvalidFormat => ( + StatusCode::BAD_REQUEST, + "InvalidToken", + "The verification token is invalid or malformed", + ), + VerifyError::UnsupportedVersion => ( + StatusCode::BAD_REQUEST, + "InvalidToken", + "This verification token version is not supported", + ), + VerifyError::Expired => ( + StatusCode::BAD_REQUEST, + "ExpiredToken", + "The verification token has expired. Please request a new one.", + ), + VerifyError::InvalidSignature => ( + StatusCode::BAD_REQUEST, + "InvalidToken", + "The verification token signature is invalid", + ), + _ => ( + StatusCode::BAD_REQUEST, + "InvalidToken", + "The verification token is not valid", + ), + }; + warn!(error = ?e, "Token verification failed"); + return Err((status, Json(json!({ "error": error, "message": message })))); + } + }; + + let expected_hash = crate::auth::verification_token::hash_identifier(&identifier); + if token_data.identifier_hash != expected_hash { + return Err(( + StatusCode::BAD_REQUEST, + Json( + json!({ "error": "IdentifierMismatch", "message": "The identifier does not match the verification token" }), + ), + )); + } + + match token_data.purpose { + VerificationPurpose::Migration => { + handle_migration_verification(state, &token_data.did, &token_data.channel, &identifier) + .await + } + VerificationPurpose::ChannelUpdate => { + let auth_did = extract_and_validate_auth(state, headers).await?; + if auth_did != token_data.did { + return Err(( + StatusCode::BAD_REQUEST, + Json( + json!({ "error": "InvalidToken", "message": "Token does not match authenticated account" }), + ), + )); + } + handle_channel_update(state, &token_data.did, &token_data.channel, &identifier).await + } + VerificationPurpose::Signup => { + handle_signup_verification(state, &token_data.did, &token_data.channel, &identifier) + .await + } + } +} + +async fn extract_and_validate_auth( + state: &AppState, + headers: Option<&HeaderMap>, +) -> Result)> { + let headers = headers.ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "AuthenticationRequired", "message": "Authentication required for this verification" })), + ) + })?; + + let token = crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) + .ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "AuthenticationRequired", "message": "Authentication required for this verification" })), + ) + })?; + + let user = crate::auth::validate_bearer_token(&state.db, &token) + .await + .map_err(|_| { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "AuthenticationFailed", "message": "Invalid authentication token" })), + ) + })?; + + Ok(user.did) +} + +async fn handle_migration_verification( + state: &AppState, + did: &str, + channel: &str, + identifier: &str, +) -> Result, (StatusCode, Json)> { + if channel != "email" { + return Err(( + StatusCode::BAD_REQUEST, + Json( + json!({ "error": "InvalidChannel", "message": "Migration verification is only supported for email" }), + ), + )); + } + + let user = sqlx::query!( + "SELECT id, email, email_verified FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await + .map_err(|e| { + warn!(error = %e, "Database error during migration verification"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "InternalError", "message": "Database error" })), + ) + })?; + + let user = user.ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })), + ) + })?; + + if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) { + return Err(( + StatusCode::BAD_REQUEST, + Json( + json!({ "error": "IdentifierMismatch", "message": "The email address does not match the account" }), + ), + )); + } + + if !user.email_verified { + sqlx::query!( + "UPDATE users SET email_verified = true WHERE id = $1", + user.id + ) + .execute(&state.db) + .await + .map_err(|e| { + warn!(error = %e, "Failed to update email_verified status"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "InternalError", "message": "Failed to verify email" })), + ) + })?; + } + + info!(did = %did, "Migration email verified successfully"); + + Ok(Json(VerifyTokenOutput { + success: true, + did: did.to_string(), + purpose: "migration".to_string(), + channel: channel.to_string(), + })) +} + +async fn handle_channel_update( + state: &AppState, + did: &str, + channel: &str, + identifier: &str, +) -> Result, (StatusCode, Json)> { + let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_one(&state.db) + .await + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "InternalError", "message": "User not found" })), + ) + })?; + + let update_result = match channel { + "email" => sqlx::query!( + "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", + identifier, + user_id + ).execute(&state.db).await, + "discord" => sqlx::query!( + "UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2", + identifier, + user_id + ).execute(&state.db).await, + "telegram" => sqlx::query!( + "UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2", + identifier, + user_id + ).execute(&state.db).await, + "signal" => sqlx::query!( + "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", + identifier, + user_id + ).execute(&state.db).await, + _ => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })), + )); + } + }; + + if let Err(e) = update_result { + error!("Failed to update user channel: {:?}", e); + if channel == "email" + && e.as_database_error() + .map(|db| db.is_unique_violation()) + .unwrap_or(false) + { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "EmailTaken", "message": "Email already in use" })), + )); + } + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "InternalError", "message": "Failed to update channel" })), + )); + } + + info!(did = %did, channel = %channel, "Channel verified successfully"); + + Ok(Json(VerifyTokenOutput { + success: true, + did: did.to_string(), + purpose: "channel_update".to_string(), + channel: channel.to_string(), + })) +} + +async fn handle_signup_verification( + state: &AppState, + did: &str, + channel: &str, + _identifier: &str, +) -> Result, (StatusCode, Json)> { + let user = sqlx::query!( + "SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await + .map_err(|e| { + warn!(error = %e, "Database error during signup verification"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "InternalError", "message": "Database error" })), + ) + })?; + + let user = user.ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })), + ) + })?; + + let is_verified = user.email_verified + || user.discord_verified + || user.telegram_verified + || user.signal_verified; + if is_verified { + info!(did = %did, "Account already verified"); + return Ok(Json(VerifyTokenOutput { + success: true, + did: did.to_string(), + purpose: "signup".to_string(), + channel: channel.to_string(), + })); + } + + let update_result = match channel { + "email" => { + sqlx::query!( + "UPDATE users SET email_verified = TRUE WHERE id = $1", + user.id + ) + .execute(&state.db) + .await + } + "discord" => { + sqlx::query!( + "UPDATE users SET discord_verified = TRUE WHERE id = $1", + user.id + ) + .execute(&state.db) + .await + } + "telegram" => { + sqlx::query!( + "UPDATE users SET telegram_verified = TRUE WHERE id = $1", + user.id + ) + .execute(&state.db) + .await + } + "signal" => { + sqlx::query!( + "UPDATE users SET signal_verified = TRUE WHERE id = $1", + user.id + ) + .execute(&state.db) + .await + } + _ => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })), + )); + } + }; + + update_result.map_err(|e| { + warn!(error = %e, "Failed to update channel verified status"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "InternalError", "message": "Failed to verify channel" })), + ) + })?; + + info!(did = %did, channel = %channel, "Signup verified successfully"); + + Ok(Json(VerifyTokenOutput { + success: true, + did: did.to_string(), + purpose: "signup".to_string(), + channel: channel.to_string(), + })) +} diff --git a/src/api/validation.rs b/src/api/validation.rs index 0731dbe..00b2347 100644 --- a/src/api/validation.rs +++ b/src/api/validation.rs @@ -64,16 +64,16 @@ pub fn validate_short_handle(handle: &str) -> Result, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), - ) - .into_response(), - }; - let user = match validate_bearer_token(&state.db, &token).await { - Ok(u) => u, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), - ) - .into_response(); - } + let token_input = crate::api::server::VerifyTokenInput { + token: input.code, + identifier: input.identifier, }; - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did) - .fetch_one(&state.db) - .await - { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User not found"})), - ) - .into_response(); - } - }; - - let channel_str = input.channel.as_str(); - if !["email", "discord", "telegram", "signal"].contains(&channel_str) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid channel"})), - ) - .into_response(); + match crate::api::server::verify_token_internal(&state, Some(&headers), token_input).await { + Ok(output) => Json(json!({"success": output.success})).into_response(), + Err((status, err_json)) => (status, err_json).into_response(), } - - let record = match sqlx::query!( - r#" - SELECT code, pending_identifier, expires_at FROM channel_verifications - WHERE user_id = $1 AND channel = $2::comms_channel - "#, - user_id, - channel_str as _ - ) - .fetch_optional(&state.db) - .await { - Ok(Some(r)) => r, - Ok(None) => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No pending verification found. Update notification preferences first."})), - ) - .into_response(), - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), - ) - .into_response(), - }; - - let pending_identifier = - match record.pending_identifier { - Some(p) => p, - None => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})), - ) - .into_response(), - }; - - if record.expires_at < Utc::now() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "ExpiredToken", "message": "Verification code expired"})), - ) - .into_response(); - } - - if record.code != input.code { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidCode", "message": "Invalid verification code"})), - ) - .into_response(); - } - - let mut tx = match state.db.begin().await { - Ok(tx) => tx, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let update_result = match channel_str { - "email" => sqlx::query!( - "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2", - pending_identifier, - user_id - ).execute(&mut *tx).await, - "discord" => sqlx::query!( - "UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2", - pending_identifier, - user_id - ).execute(&mut *tx).await, - "telegram" => sqlx::query!( - "UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2", - pending_identifier, - user_id - ).execute(&mut *tx).await, - "signal" => sqlx::query!( - "UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2", - pending_identifier, - user_id - ).execute(&mut *tx).await, - _ => unreachable!(), - }; - - if let Err(e) = update_result { - error!("Failed to update user channel: {:?}", e); - if channel_str == "email" - && e.as_database_error() - .map(|db| db.is_unique_violation()) - .unwrap_or(false) - { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "EmailTaken", "message": "Email already in use"})), - ) - .into_response(); - } - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to update channel"})), - ) - .into_response(); - } - - if let Err(e) = sqlx::query!( - "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel", - user_id, - channel_str as _ - ) - .execute(&mut *tx) - .await - { - error!("Failed to delete verification record: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - - if tx.commit().await.is_err() { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - - info!(did = %user.did, channel = %channel_str, "Channel verified successfully"); - - Json(json!({"success": true})).into_response() } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index d86dcff..cff08dc 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -12,6 +12,7 @@ pub mod scope_check; pub mod service; pub mod token; pub mod totp; +pub mod verification_token; pub mod verify; pub mod webauthn; diff --git a/src/auth/verification_token.rs b/src/auth/verification_token.rs new file mode 100644 index 0000000..033801c --- /dev/null +++ b/src/auth/verification_token.rs @@ -0,0 +1,423 @@ +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::Mac; +use sha2::{Digest, Sha256}; + +type HmacSha256 = hmac::Hmac; + +const TOKEN_VERSION: u8 = 1; +const DEFAULT_SIGNUP_EXPIRY_MINUTES: u64 = 30; +const DEFAULT_MIGRATION_EXPIRY_HOURS: u64 = 48; +const DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES: u64 = 10; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerificationPurpose { + Signup, + Migration, + ChannelUpdate, +} + +impl VerificationPurpose { + fn as_str(&self) -> &'static str { + match self { + Self::Signup => "signup", + Self::Migration => "migration", + Self::ChannelUpdate => "channel_update", + } + } + + fn from_str(s: &str) -> Option { + match s { + "signup" => Some(Self::Signup), + "migration" => Some(Self::Migration), + "channel_update" => Some(Self::ChannelUpdate), + _ => None, + } + } + + fn default_expiry_seconds(&self) -> u64 { + match self { + Self::Signup => DEFAULT_SIGNUP_EXPIRY_MINUTES * 60, + Self::Migration => DEFAULT_MIGRATION_EXPIRY_HOURS * 3600, + Self::ChannelUpdate => DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES * 60, + } + } +} + +#[derive(Debug)] +pub struct VerificationToken { + pub did: String, + pub purpose: VerificationPurpose, + pub channel: String, + pub identifier_hash: String, + pub expires_at: u64, +} + +fn derive_verification_key() -> [u8; 32] { + use hkdf::Hkdf; + let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| { + if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() { + "test-master-key-not-for-production".to_string() + } else { + panic!("MASTER_KEY must be set"); + } + }); + let hk = Hkdf::::new(None, master_key.as_bytes()); + let mut key = [0u8; 32]; + hk.expand(b"tranquil-pds-verification-token-v1", &mut key) + .expect("HKDF expansion failed"); + key +} + +pub fn hash_identifier(identifier: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(identifier.to_lowercase().as_bytes()); + let result = hasher.finalize(); + URL_SAFE_NO_PAD.encode(&result[..16]) +} + +pub fn generate_signup_token(did: &str, channel: &str, identifier: &str) -> String { + generate_token(did, VerificationPurpose::Signup, channel, identifier) +} + +pub fn generate_migration_token(did: &str, email: &str) -> String { + generate_token(did, VerificationPurpose::Migration, "email", email) +} + +pub fn generate_channel_update_token(did: &str, channel: &str, identifier: &str) -> String { + generate_token(did, VerificationPurpose::ChannelUpdate, channel, identifier) +} + +pub fn generate_token( + did: &str, + purpose: VerificationPurpose, + channel: &str, + identifier: &str, +) -> String { + generate_token_with_expiry( + did, + purpose, + channel, + identifier, + purpose.default_expiry_seconds(), + ) +} + +pub fn generate_token_with_expiry( + did: &str, + purpose: VerificationPurpose, + channel: &str, + identifier: &str, + expiry_seconds: u64, +) -> String { + let key = derive_verification_key(); + let identifier_hash = hash_identifier(identifier); + let expires_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + + expiry_seconds; + + let payload = format!( + "{}|{}|{}|{}|{}", + did, + purpose.as_str(), + channel, + identifier_hash, + expires_at + ); + + let mut mac = ::new_from_slice(&key).expect("HMAC key size is valid"); + mac.update(payload.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + + let token_data = format!( + "{}|{}|{}|{}|{}|{}|{}", + TOKEN_VERSION, + did, + purpose.as_str(), + channel, + identifier_hash, + expires_at, + signature + ); + URL_SAFE_NO_PAD.encode(token_data.as_bytes()) +} + +#[derive(Debug)] +pub enum VerifyError { + InvalidFormat, + UnsupportedVersion, + Expired, + InvalidSignature, + IdentifierMismatch, + PurposeMismatch, + ChannelMismatch, +} + +impl std::fmt::Display for VerifyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidFormat => write!(f, "Invalid token format"), + Self::UnsupportedVersion => write!(f, "Unsupported token version"), + Self::Expired => write!(f, "Token has expired"), + Self::InvalidSignature => write!(f, "Invalid token signature"), + Self::IdentifierMismatch => write!(f, "Identifier does not match token"), + Self::PurposeMismatch => write!(f, "Token purpose does not match"), + Self::ChannelMismatch => write!(f, "Token channel does not match"), + } + } +} + +pub fn verify_signup_token( + token: &str, + expected_channel: &str, + expected_identifier: &str, +) -> Result { + let parsed = verify_token_signature(token)?; + if parsed.purpose != VerificationPurpose::Signup { + return Err(VerifyError::PurposeMismatch); + } + if parsed.channel != expected_channel { + return Err(VerifyError::ChannelMismatch); + } + let expected_hash = hash_identifier(expected_identifier); + if parsed.identifier_hash != expected_hash { + return Err(VerifyError::IdentifierMismatch); + } + Ok(parsed) +} + +pub fn verify_migration_token( + token: &str, + expected_email: &str, +) -> Result { + let parsed = verify_token_signature(token)?; + if parsed.purpose != VerificationPurpose::Migration { + return Err(VerifyError::PurposeMismatch); + } + if parsed.channel != "email" { + return Err(VerifyError::ChannelMismatch); + } + let expected_hash = hash_identifier(expected_email); + if parsed.identifier_hash != expected_hash { + return Err(VerifyError::IdentifierMismatch); + } + Ok(parsed) +} + +pub fn verify_channel_update_token( + token: &str, + expected_channel: &str, + expected_identifier: &str, +) -> Result { + let parsed = verify_token_signature(token)?; + if parsed.purpose != VerificationPurpose::ChannelUpdate { + return Err(VerifyError::PurposeMismatch); + } + if parsed.channel != expected_channel { + return Err(VerifyError::ChannelMismatch); + } + let expected_hash = hash_identifier(expected_identifier); + if parsed.identifier_hash != expected_hash { + return Err(VerifyError::IdentifierMismatch); + } + Ok(parsed) +} + +pub fn verify_token_for_did( + token: &str, + expected_did: &str, +) -> Result { + let parsed = verify_token_signature(token)?; + if parsed.did != expected_did { + return Err(VerifyError::IdentifierMismatch); + } + Ok(parsed) +} + +pub fn verify_token_signature(token: &str) -> Result { + let token_bytes = URL_SAFE_NO_PAD + .decode(token.trim()) + .map_err(|_| VerifyError::InvalidFormat)?; + let token_str = String::from_utf8(token_bytes).map_err(|_| VerifyError::InvalidFormat)?; + + let parts: Vec<&str> = token_str.split('|').collect(); + if parts.len() != 7 { + return Err(VerifyError::InvalidFormat); + } + + let version: u8 = parts[0].parse().map_err(|_| VerifyError::InvalidFormat)?; + if version != TOKEN_VERSION { + return Err(VerifyError::UnsupportedVersion); + } + + let did = parts[1]; + let purpose_str = parts[2]; + let channel = parts[3]; + let identifier_hash = parts[4]; + let expires_at: u64 = parts[5].parse().map_err(|_| VerifyError::InvalidFormat)?; + let provided_signature = parts[6]; + + let purpose = VerificationPurpose::from_str(purpose_str).ok_or(VerifyError::InvalidFormat)?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if now > expires_at { + return Err(VerifyError::Expired); + } + + let key = derive_verification_key(); + let payload = format!( + "{}|{}|{}|{}|{}", + did, purpose_str, channel, identifier_hash, expires_at + ); + let mut mac = ::new_from_slice(&key).expect("HMAC key size is valid"); + mac.update(payload.as_bytes()); + let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + + use subtle::ConstantTimeEq; + let sig_matches: bool = provided_signature + .as_bytes() + .ct_eq(expected_signature.as_bytes()) + .into(); + if !sig_matches { + return Err(VerifyError::InvalidSignature); + } + + Ok(VerificationToken { + did: did.to_string(), + purpose, + channel: channel.to_string(), + identifier_hash: identifier_hash.to_string(), + expires_at, + }) +} + +pub fn format_token_for_display(token: &str) -> String { + let clean = token.replace(['-', ' '], ""); + let mut result = String::new(); + for (i, c) in clean.chars().enumerate() { + if i > 0 && i % 4 == 0 { + result.push('-'); + } + result.push(c); + } + result +} + +pub fn normalize_token_input(input: &str) -> String { + input + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '=') + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_signup_token() { + let did = "did:plc:test123"; + let channel = "email"; + let identifier = "test@example.com"; + let token = generate_signup_token(did, channel, identifier); + let result = verify_signup_token(&token, channel, identifier); + assert!(result.is_ok(), "Expected Ok, got {:?}", result); + let parsed = result.unwrap(); + assert_eq!(parsed.did, did); + assert_eq!(parsed.purpose, VerificationPurpose::Signup); + assert_eq!(parsed.channel, channel); + } + + #[test] + fn test_migration_token() { + let did = "did:plc:test123"; + let email = "test@example.com"; + let token = generate_migration_token(did, email); + let result = verify_migration_token(&token, email); + assert!(result.is_ok(), "Expected Ok, got {:?}", result); + let parsed = result.unwrap(); + assert_eq!(parsed.did, did); + assert_eq!(parsed.purpose, VerificationPurpose::Migration); + } + + #[test] + fn test_token_case_insensitive() { + let did = "did:plc:test123"; + let token = generate_signup_token(did, "email", "Test@Example.COM"); + let result = verify_signup_token(&token, "email", "test@example.com"); + assert!(result.is_ok()); + } + + #[test] + fn test_token_wrong_identifier() { + let did = "did:plc:test123"; + let token = generate_signup_token(did, "email", "test@example.com"); + let result = verify_signup_token(&token, "email", "other@example.com"); + assert!(matches!(result, Err(VerifyError::IdentifierMismatch))); + } + + #[test] + fn test_token_wrong_channel() { + let did = "did:plc:test123"; + let token = generate_signup_token(did, "email", "test@example.com"); + let result = verify_signup_token(&token, "discord", "test@example.com"); + assert!(matches!(result, Err(VerifyError::ChannelMismatch))); + } + + #[test] + fn test_expired_token() { + let did = "did:plc:test123"; + let token = generate_token_with_expiry( + did, + VerificationPurpose::Signup, + "email", + "test@example.com", + 0, + ); + std::thread::sleep(std::time::Duration::from_millis(1100)); + let result = verify_signup_token(&token, "email", "test@example.com"); + assert!(matches!(result, Err(VerifyError::Expired))); + } + + #[test] + fn test_invalid_token() { + let result = verify_signup_token("invalid-token", "email", "test@example.com"); + assert!(matches!(result, Err(VerifyError::InvalidFormat))); + } + + #[test] + fn test_purpose_mismatch() { + let did = "did:plc:test123"; + let email = "test@example.com"; + let signup_token = generate_signup_token(did, "email", email); + let result = verify_migration_token(&signup_token, email); + assert!(matches!(result, Err(VerifyError::PurposeMismatch))); + } + + #[test] + fn test_discord_channel() { + let did = "did:plc:test123"; + let discord_id = "123456789012345678"; + let token = generate_signup_token(did, "discord", discord_id); + let result = verify_signup_token(&token, "discord", discord_id); + assert!(result.is_ok()); + } + + #[test] + fn test_format_token_for_display() { + let token = "ABCDEFGHIJKLMNOP"; + let formatted = format_token_for_display(token); + assert_eq!(formatted, "ABCD-EFGH-IJKL-MNOP"); + } + + #[test] + fn test_normalize_token_input() { + let input = "ABCD-EFGH IJKL-MNOP"; + let normalized = normalize_token_input(input); + assert_eq!(normalized, "ABCDEFGHIJKLMNOP"); + } +} diff --git a/src/comms/locale.rs b/src/comms/locale.rs index 7e80bd3..895d7e5 100644 --- a/src/comms/locale.rs +++ b/src/comms/locale.rs @@ -12,8 +12,6 @@ pub fn validate_locale(locale: &str) -> &str { pub struct NotificationStrings { pub welcome_subject: &'static str, pub welcome_body: &'static str, - pub email_verification_subject: &'static str, - pub email_verification_body: &'static str, pub password_reset_subject: &'static str, pub password_reset_body: &'static str, pub email_update_subject: &'static str, @@ -30,6 +28,8 @@ pub struct NotificationStrings { pub signup_verification_body: &'static str, pub legacy_login_subject: &'static str, pub legacy_login_body: &'static str, + pub migration_verification_subject: &'static str, + pub migration_verification_body: &'static str, } pub fn get_strings(locale: &str) -> &'static NotificationStrings { @@ -46,12 +46,10 @@ pub fn get_strings(locale: &str) -> &'static NotificationStrings { static STRINGS_EN: NotificationStrings = NotificationStrings { welcome_subject: "Welcome to {hostname}", welcome_body: "Welcome to {hostname}!\n\nYour handle is: @{handle}\n\nThank you for joining us.", - email_verification_subject: "Verify your email - {hostname}", - email_verification_body: "Hello @{handle},\n\nYour email verification code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.", password_reset_subject: "Password Reset - {hostname}", password_reset_body: "Hello @{handle},\n\nYour password reset code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.", email_update_subject: "Confirm your new email - {hostname}", - email_update_body: "Hello @{handle},\n\nYour email update confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.", + email_update_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.\n\n(Or if you like to live dangerously: {verify_link})", account_deletion_subject: "Account Deletion Request - {hostname}", account_deletion_body: "Hello @{handle},\n\nYour account deletion confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", plc_operation_subject: "{hostname} - PLC Operation Token", @@ -61,20 +59,20 @@ static STRINGS_EN: NotificationStrings = NotificationStrings { passkey_recovery_subject: "Account Recovery - {hostname}", passkey_recovery_body: "Hello @{handle},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{url}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.", signup_verification_subject: "Verify your account - {hostname}", - signup_verification_body: "Welcome! Your account verification code is: {code}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {hostname}.", + signup_verification_body: "Welcome! Your verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 30 minutes.\n\nIf you did not create an account on {hostname}, please ignore this message.\n\n(Or if you like to live dangerously: {verify_link})", legacy_login_subject: "Security Alert: Legacy Login Detected - {hostname}", legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}", + migration_verification_subject: "Verify your email - {hostname}", + migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nIf you did not migrate your account, please ignore this email.\n\n(Or if you like to live dangerously: {verify_link})", }; static STRINGS_ZH: NotificationStrings = NotificationStrings { welcome_subject: "欢迎加入 {hostname}", welcome_body: "欢迎加入 {hostname}!\n\n您的用户名是:@{handle}\n\n感谢您的加入。", - email_verification_subject: "验证您的邮箱 - {hostname}", - email_verification_body: "您好 @{handle},\n\n您的邮箱验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。", password_reset_subject: "密码重置 - {hostname}", password_reset_body: "您好 @{handle},\n\n您的密码重置验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。", email_update_subject: "确认您的新邮箱 - {hostname}", - email_update_body: "您好 @{handle},\n\n您的邮箱更新确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。", + email_update_body: "您好 @{handle},\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。\n\n(或者直接点击链接:{verify_link})", account_deletion_subject: "账户删除请求 - {hostname}", account_deletion_body: "您好 @{handle},\n\n您的账户删除确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。", plc_operation_subject: "{hostname} - PLC 操作令牌", @@ -84,20 +82,20 @@ static STRINGS_ZH: NotificationStrings = NotificationStrings { passkey_recovery_subject: "账户恢复 - {hostname}", passkey_recovery_body: "您好 @{handle},\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{url}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。", signup_verification_subject: "验证您的账户 - {hostname}", - signup_verification_body: "欢迎!您的账户验证码是:{code}\n\n此验证码将在30分钟后过期。\n\n请输入此验证码完成在 {hostname} 上的注册。", + signup_verification_body: "欢迎!您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在30分钟后过期。\n\n如果您没有在 {hostname} 上创建账户,请忽略此消息。\n\n(或者直接点击链接:{verify_link})", legacy_login_subject: "安全提醒:检测到传统应用登录 - {hostname}", legacy_login_body: "您好 @{handle},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}", + migration_verification_subject: "验证您的邮箱 - {hostname}", + migration_verification_body: "欢迎来到 {hostname}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n如果您没有迁移账户,请忽略此邮件。\n\n(或者直接点击链接:{verify_link})", }; static STRINGS_JA: NotificationStrings = NotificationStrings { welcome_subject: "{hostname} へようこそ", welcome_body: "{hostname} へようこそ!\n\nお客様のハンドル:@{handle}\n\nご登録ありがとうございます。", - email_verification_subject: "メール認証 - {hostname}", - email_verification_body: "@{handle} 様\n\nメール認証コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。", password_reset_subject: "パスワードリセット - {hostname}", password_reset_body: "@{handle} 様\n\nパスワードリセットコードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。", email_update_subject: "新しいメールアドレスの確認 - {hostname}", - email_update_body: "@{handle} 様\n\nメールアドレス更新の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。", + email_update_body: "@{handle} 様\n\n確認コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})", account_deletion_subject: "アカウント削除リクエスト - {hostname}", account_deletion_body: "@{handle} 様\n\nアカウント削除の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。", plc_operation_subject: "{hostname} - PLC 操作トークン", @@ -107,20 +105,20 @@ static STRINGS_JA: NotificationStrings = NotificationStrings { passkey_recovery_subject: "アカウント復旧 - {hostname}", passkey_recovery_body: "@{handle} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{url}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。", signup_verification_subject: "アカウント認証 - {hostname}", - signup_verification_body: "ようこそ!アカウント認証コードは:{code}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} への登録を完了するには、このコードを入力してください。", + signup_verification_body: "ようこそ!認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} でアカウントを作成していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})", legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {hostname}", legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}", + migration_verification_subject: "メールアドレスの認証 - {hostname}", + migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\nアカウントを移行していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})", }; static STRINGS_KO: NotificationStrings = NotificationStrings { welcome_subject: "{hostname}에 오신 것을 환영합니다", welcome_body: "{hostname}에 오신 것을 환영합니다!\n\n회원님의 핸들은: @{handle}\n\n가입해 주셔서 감사합니다.", - email_verification_subject: "이메일 인증 - {hostname}", - email_verification_body: "안녕하세요 @{handle}님,\n\n이메일 인증 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.", password_reset_subject: "비밀번호 재설정 - {hostname}", password_reset_body: "안녕하세요 @{handle}님,\n\n비밀번호 재설정 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요.", - email_update_subject: "새 이메일 확인 - {hostname}", - email_update_body: "안녕하세요 @{handle}님,\n\n이메일 업데이트 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.", + email_update_subject: "새 이메일 주소 확인 - {hostname}", + email_update_body: "안녕하세요 @{handle}님,\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})", account_deletion_subject: "계정 삭제 요청 - {hostname}", account_deletion_body: "안녕하세요 @{handle}님,\n\n계정 삭제 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.", plc_operation_subject: "{hostname} - PLC 작업 토큰", @@ -130,20 +128,20 @@ static STRINGS_KO: NotificationStrings = NotificationStrings { passkey_recovery_subject: "계정 복구 - {hostname}", passkey_recovery_body: "안녕하세요 @{handle}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{url}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.", signup_verification_subject: "계정 인증 - {hostname}", - signup_verification_body: "환영합니다! 계정 인증 코드는: {code}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 등록을 완료하려면 이 코드를 입력하세요.", + signup_verification_body: "환영합니다! 인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 계정을 만들지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})", legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {hostname}", legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림", + migration_verification_subject: "이메일 인증 - {hostname}", + migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})", }; static STRINGS_SV: NotificationStrings = NotificationStrings { welcome_subject: "Välkommen till {hostname}", welcome_body: "Välkommen till {hostname}!\n\nDitt användarnamn är: @{handle}\n\nTack för att du gick med.", - email_verification_subject: "Verifiera din e-post - {hostname}", - email_verification_body: "Hej @{handle},\n\nDin e-postverifieringskod är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.", password_reset_subject: "Lösenordsåterställning - {hostname}", password_reset_body: "Hej @{handle},\n\nDin kod för lösenordsåterställning är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.", email_update_subject: "Bekräfta din nya e-post - {hostname}", - email_update_body: "Hej @{handle},\n\nDin bekräftelsekod för e-postuppdatering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.", + email_update_body: "Hej @{handle},\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})", account_deletion_subject: "Begäran om kontoradering - {hostname}", account_deletion_body: "Hej @{handle},\n\nDin bekräftelsekod för kontoradering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta, skydda ditt konto omedelbart.", plc_operation_subject: "{hostname} - PLC-operationstoken", @@ -153,20 +151,20 @@ static STRINGS_SV: NotificationStrings = NotificationStrings { passkey_recovery_subject: "Kontoåterställning - {hostname}", passkey_recovery_body: "Hej @{handle},\n\nDu begärde att återställa ditt endast nyckelkonto.\n\nKlicka på länken nedan för att ställa in ett tillfälligt lösenord och återfå åtkomst:\n{url}\n\nDenna länk upphör om 1 timme.\n\nOm du inte begärde detta kan du ignorera detta meddelande. Ditt konto förblir säkert.", signup_verification_subject: "Verifiera ditt konto - {hostname}", - signup_verification_body: "Välkommen! Din kontoverifieringskod är: {code}\n\nDenna kod upphör om 30 minuter.\n\nAnge denna kod för att slutföra din registrering på {hostname}.", + signup_verification_body: "Välkommen! Din verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 30 minuter.\n\nOm du inte skapade ett konto på {hostname}, ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})", legacy_login_subject: "Säkerhetsvarning: Äldre inloggning upptäckt - {hostname}", legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}", + migration_verification_subject: "Verifiera din e-post - {hostname}", + migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})", }; static STRINGS_FI: NotificationStrings = NotificationStrings { welcome_subject: "Tervetuloa palveluun {hostname}", welcome_body: "Tervetuloa palveluun {hostname}!\n\nKäyttäjänimesi on: @{handle}\n\nKiitos liittymisestä.", - email_verification_subject: "Vahvista sähköpostisi - {hostname}", - email_verification_body: "Hei @{handle},\n\nSähköpostin vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", password_reset_subject: "Salasanan palautus - {hostname}", password_reset_body: "Hei @{handle},\n\nSalasanan palautuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", - email_update_subject: "Vahvista uusi sähköpostiosoitteesi - {hostname}", - email_update_body: "Hei @{handle},\n\nSähköpostin päivityksen vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.", + email_update_subject: "Vahvista uusi sähköpostisi - {hostname}", + email_update_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})", account_deletion_subject: "Tilin poistopyyntö - {hostname}", account_deletion_body: "Hei @{handle},\n\nTilin poiston vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, suojaa tilisi välittömästi.", plc_operation_subject: "{hostname} - PLC-toimintotunniste", @@ -176,9 +174,11 @@ static STRINGS_FI: NotificationStrings = NotificationStrings { passkey_recovery_subject: "Tilin palautus - {hostname}", passkey_recovery_body: "Hei @{handle},\n\nPyysit palauttamaan vain pääsyavaintilisi.\n\nKlikkaa alla olevaa linkkiä asettaaksesi väliaikaisen salasanan ja saadaksesi pääsyn takaisin:\n{url}\n\nTämä linkki vanhenee tunnissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta. Tilisi pysyy turvassa.", signup_verification_subject: "Vahvista tilisi - {hostname}", - signup_verification_body: "Tervetuloa! Tilin vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 30 minuutissa.\n\nSyötä tämä koodi viimeistelläksesi rekisteröintisi palveluun {hostname}.", + signup_verification_body: "Tervetuloa! Vahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 30 minuutissa.\n\nJos et luonut tiliä palveluun {hostname}, jätä tämä viesti huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})", legacy_login_subject: "Turvallisuushälytys: Vanha kirjautuminen havaittu - {hostname}", legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}", + migration_verification_subject: "Vahvista sähköpostisi - {hostname}", + migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})", }; pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String { diff --git a/src/comms/mod.rs b/src/comms/mod.rs index 2ebe6bb..bed85f8 100644 --- a/src/comms/mod.rs +++ b/src/comms/mod.rs @@ -10,7 +10,7 @@ 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_passkey_recovery, + enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery, enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, queue_legacy_login_notification, }; diff --git a/src/comms/service.rs b/src/comms/service.rs index 271f7f9..358212f 100644 --- a/src/comms/service.rs +++ b/src/comms/service.rs @@ -313,34 +313,6 @@ pub async fn enqueue_welcome( .await } -pub async fn enqueue_email_verification( - db: &PgPool, - user_id: Uuid, - email: &str, - handle: &str, - code: &str, - hostname: &str, -) -> Result { - let prefs = get_user_comms_prefs(db, user_id).await?; - let strings = get_strings(&prefs.locale); - let body = format_message( - strings.email_verification_body, - &[("handle", handle), ("code", code)], - ); - let subject = format_message(strings.email_verification_subject, &[("hostname", hostname)]); - enqueue_comms( - db, - NewComms::email( - user_id, - super::types::CommsType::EmailVerification, - email.to_string(), - subject, - body, - ), - ) - .await -} - pub async fn enqueue_password_reset( db: &PgPool, user_id: Uuid, @@ -378,9 +350,21 @@ pub async fn enqueue_email_update( ) -> Result { let prefs = get_user_comms_prefs(db, user_id).await?; let strings = get_strings(&prefs.locale); + let encoded_email = urlencoding::encode(new_email); + let encoded_token = urlencoding::encode(code); + let verify_page = format!("https://{}/#/verify", hostname); + let verify_link = format!( + "https://{}/#/verify?token={}&identifier={}", + hostname, encoded_token, encoded_email + ); let body = format_message( strings.email_update_body, - &[("handle", handle), ("code", code)], + &[ + ("handle", handle), + ("code", code), + ("verify_page", &verify_page), + ("verify_link", &verify_link), + ], ); let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]); enqueue_comms( @@ -530,14 +514,33 @@ pub async fn enqueue_signup_verification( _ => CommsChannel::Email, }; let strings = get_strings(locale.unwrap_or("en")); + let (verify_page, verify_link) = if comms_channel == CommsChannel::Email { + let encoded_email = urlencoding::encode(recipient); + let encoded_token = urlencoding::encode(code); + ( + format!("https://{}/#/verify", hostname), + format!( + "https://{}/#/verify?token={}&identifier={}", + hostname, encoded_token, encoded_email + ), + ) + } else { + (String::new(), String::new()) + }; let body = format_message( strings.signup_verification_body, - &[("code", code), ("hostname", &hostname)], + &[ + ("code", code), + ("hostname", &hostname), + ("verify_page", &verify_page), + ("verify_link", &verify_link), + ], ); let subject = match comms_channel { - CommsChannel::Email => { - Some(format_message(strings.signup_verification_subject, &[("hostname", &hostname)])) - } + CommsChannel::Email => Some(format_message( + strings.signup_verification_subject, + &[("hostname", &hostname)], + )), _ => None, }; enqueue_comms( @@ -554,6 +557,48 @@ pub async fn enqueue_signup_verification( .await } +pub async fn enqueue_migration_verification( + db: &PgPool, + user_id: Uuid, + email: &str, + token: &str, + hostname: &str, +) -> Result { + let prefs = get_user_comms_prefs(db, user_id).await?; + let strings = get_strings(&prefs.locale); + let encoded_email = urlencoding::encode(email); + let encoded_token = urlencoding::encode(token); + let verify_page = format!("https://{}/#/verify", hostname); + let verify_link = format!( + "https://{}/#/verify?token={}&identifier={}", + hostname, encoded_token, encoded_email + ); + let body = format_message( + strings.migration_verification_body, + &[ + ("code", token), + ("hostname", hostname), + ("verify_page", &verify_page), + ("verify_link", &verify_link), + ], + ); + let subject = format_message( + strings.migration_verification_subject, + &[("hostname", hostname)], + ); + enqueue_comms( + db, + NewComms::email( + user_id, + super::types::CommsType::MigrationVerification, + email.to_string(), + subject, + body, + ), + ) + .await +} + pub async fn queue_legacy_login_notification( db: &PgPool, user_id: Uuid, @@ -563,7 +608,9 @@ pub async fn queue_legacy_login_notification( ) -> Result { let prefs = get_user_comms_prefs(db, user_id).await?; let strings = get_strings(&prefs.locale); - let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); + let timestamp = chrono::Utc::now() + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(); let body = format_message( strings.legacy_login_body, &[ diff --git a/src/comms/types.rs b/src/comms/types.rs index 43441b7..a21e1b0 100644 --- a/src/comms/types.rs +++ b/src/comms/types.rs @@ -34,6 +34,7 @@ pub enum CommsType { TwoFactorCode, PasskeyRecovery, LegacyLoginAlert, + MigrationVerification, } #[derive(Debug, Clone, FromRow)] diff --git a/src/config.rs b/src/config.rs index 05d6aa4..578ba45 100644 --- a/src/config.rs +++ b/src/config.rs @@ -114,8 +114,11 @@ impl AuthConfig { .expect("HKDF expansion failed"); let mut device_cookie_key = [0u8; 32]; - hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key) - .expect("HKDF expansion failed"); + hk.expand( + b"tranquil-pds-device-cookie-signing", + &mut device_cookie_key, + ) + .expect("HKDF expansion failed"); AuthConfig { jwt_secret, diff --git a/src/lib.rs b/src/lib.rs index 021a6f9..363162f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -295,6 +295,14 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.server.reserveSigningKey", post(api::server::reserve_signing_key), ) + .route( + "/xrpc/com.atproto.server.verifyMigrationEmail", + post(api::server::verify_migration_email), + ) + .route( + "/xrpc/com.atproto.server.resendMigrationVerification", + post(api::server::resend_migration_verification), + ) .route( "/xrpc/com.atproto.identity.updateHandle", post(api::identity::update_handle), @@ -550,6 +558,10 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.tranquil.account.confirmChannelVerification", post(api::verification::confirm_channel_verification), ) + .route( + "/xrpc/com.tranquil.account.verifyToken", + post(api::server::verify_token), + ) .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) .layer(middleware::from_fn(metrics::metrics_middleware)) .layer( diff --git a/src/oauth/endpoints/metadata.rs b/src/oauth/endpoints/metadata.rs index 6d35ca6..942d514 100644 --- a/src/oauth/endpoints/metadata.rs +++ b/src/oauth/endpoints/metadata.rs @@ -172,7 +172,8 @@ pub async fn frontend_client_metadata( "refresh_token".to_string(), ], response_types: vec!["code".to_string()], - scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(), + scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*" + .to_string(), token_endpoint_auth_method: "none".to_string(), application_type: "web".to_string(), dpop_bound_access_tokens: true, diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 663a78e..6430055 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -74,9 +74,10 @@ impl RateLimiters { email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( NonZeroU32::new(5).unwrap(), ))), - totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60)) - .unwrap() - .allow_burst(NonZeroU32::new(5).unwrap()), + totp_verify: Arc::new(RateLimiter::keyed( + Quota::with_period(std::time::Duration::from_secs(60)) + .unwrap() + .allow_burst(NonZeroU32::new(5).unwrap()), )), } } diff --git a/src/validation/mod.rs b/src/validation/mod.rs index 5ce440a..6141f76 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -458,19 +458,57 @@ pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> fn is_common_password(password: &str) -> bool { const COMMON_PASSWORDS: &[&str] = &[ - "password", "Password1", "Password123", "Passw0rd", "Passw0rd!", - "12345678", "123456789", "1234567890", - "qwerty123", "Qwerty123", "qwertyui", "Qwertyui", - "letmein1", "Letmein1", "welcome1", "Welcome1", - "admin123", "Admin123", "password1", "Password1!", - "iloveyou", "Iloveyou1", "monkey123", "Monkey123", - "dragon12", "Dragon123", "master12", "Master123", - "login123", "Login123", "abc12345", "Abc12345", - "football", "Football1", "baseball", "Baseball1", - "trustno1", "Trustno1", "sunshine", "Sunshine1", - "princess", "Princess1", "computer", "Computer1", - "whatever", "Whatever1", "nintendo", "Nintendo1", - "bluesky1", "Bluesky1", "Bluesky123", + "password", + "Password1", + "Password123", + "Passw0rd", + "Passw0rd!", + "12345678", + "123456789", + "1234567890", + "qwerty123", + "Qwerty123", + "qwertyui", + "Qwertyui", + "letmein1", + "Letmein1", + "welcome1", + "Welcome1", + "admin123", + "Admin123", + "password1", + "Password1!", + "iloveyou", + "Iloveyou1", + "monkey123", + "Monkey123", + "dragon12", + "Dragon123", + "master12", + "Master123", + "login123", + "Login123", + "abc12345", + "Abc12345", + "football", + "Football1", + "baseball", + "Baseball1", + "trustno1", + "Trustno1", + "sunshine", + "Sunshine1", + "princess", + "Princess1", + "computer", + "Computer1", + "whatever", + "Whatever1", + "nintendo", + "Nintendo1", + "bluesky1", + "Bluesky1", + "Bluesky123", ]; let lower = password.to_lowercase(); diff --git a/tests/account_notifications.rs b/tests/account_notifications.rs index 87ba309..a8cb906 100644 --- a/tests/account_notifications.rs +++ b/tests/account_notifications.rs @@ -92,16 +92,24 @@ async fn test_verify_channel_discord() { .await .expect("User not found"); - let code: String = sqlx::query_scalar!( - "SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'", + let row = sqlx::query!( + "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1", user_id ) .fetch_one(&pool) .await .expect("Verification code not found"); + let code = row + .metadata + .as_ref() + .and_then(|m| m.get("code")) + .and_then(|c| c.as_str()) + .expect("No code in metadata"); + let input = json!({ "channel": "discord", + "identifier": "123456789", "code": code }); let resp = client @@ -153,7 +161,8 @@ async fn test_verify_channel_invalid_code() { let input = json!({ "channel": "telegram", - "code": "000000" + "identifier": "testuser", + "code": "XXXX-XXXX-XXXX-XXXX" }); let resp = client .post(format!( @@ -165,7 +174,11 @@ async fn test_verify_channel_invalid_code() { .send() .await .unwrap(); - assert_eq!(resp.status(), 400); + assert!( + resp.status() == 400 || resp.status() == 422, + "Expected 400 or 422, got {}", + resp.status() + ); } #[tokio::test] @@ -176,7 +189,8 @@ async fn test_verify_channel_not_set() { let input = json!({ "channel": "signal", - "code": "123456" + "identifier": "123456", + "code": "XXXX-XXXX-XXXX-XXXX" }); let resp = client .post(format!( @@ -188,7 +202,11 @@ async fn test_verify_channel_not_set() { .send() .await .unwrap(); - assert_eq!(resp.status(), 400); + assert!( + resp.status() == 400 || resp.status() == 422, + "Expected 400 or 422, got {}", + resp.status() + ); } #[tokio::test] @@ -226,16 +244,34 @@ async fn test_update_email_via_notification_prefs() { .await .expect("User not found"); - let code: String = sqlx::query_scalar!( - "SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'email'", + let body_text: String = sqlx::query_scalar!( + "SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", user_id ) .fetch_one(&pool) .await .expect("Verification code not found"); + let code = body_text + .lines() + .skip_while(|line| !line.contains("verification code")) + .nth(1) + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty() && line.contains('-')) + .unwrap_or_else(|| { + body_text + .lines() + .find(|line| { + let trimmed = line.trim(); + trimmed.starts_with("MX") && trimmed.contains('-') + }) + .map(|s| s.trim().to_string()) + .unwrap_or_default() + }); + let input = json!({ "channel": "email", + "identifier": unique_email, "code": code }); let resp = client diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f820f93..7e4fd74 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -297,14 +297,34 @@ pub async fn verify_new_account(client: &Client, did: &str) -> String { .connect(&conn_str) .await .expect("Failed to connect to test database"); - let verification_code: String = sqlx::query_scalar!( - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", + let body_text: String = sqlx::query_scalar!( + "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", did ) .fetch_one(&pool) .await .expect("Failed to get verification code"); + let verification_code = body_text + .lines() + .find(|line| line.contains("verification code:") || line.contains("code is:")) + .and_then(|line| { + if line.contains("verification code:") { + line.split("verification code:") + .nth(1) + .map(|s| s.trim().to_string()) + } else { + line.split("code is:").nth(1).map(|s| s.trim().to_string()) + } + }) + .unwrap_or_else(|| { + body_text + .lines() + .find(|line| line.trim().starts_with("MX") && line.contains('-')) + .map(|s| s.trim().to_string()) + .unwrap_or_default() + }); + let confirm_payload = json!({ "did": did, "verificationCode": verification_code @@ -453,13 +473,36 @@ async fn create_account_and_login_internal(client: &Client, make_admin: bool) -> if let Some(access_jwt) = body["accessJwt"].as_str() { return (access_jwt.to_string(), did); } - let verification_code: String = sqlx::query_scalar!( - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", + let body_text: String = sqlx::query_scalar!( + "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", &did ) .fetch_one(&pool) .await - .expect("Failed to get verification code"); + .expect("Failed to get verification from comms_queue"); + let verification_code = body_text + .lines() + .find(|line| line.contains("verification code:") || line.contains("code is:")) + .and_then(|line| { + if line.contains("verification code:") { + line.split("verification code:") + .nth(1) + .map(|s| s.trim().to_string()) + } else if line.contains("code is:") { + line.split("code is:").nth(1).map(|s| s.trim().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| { + body_text + .split_whitespace() + .find(|word| { + word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 + }) + .unwrap_or(&body_text) + .to_string() + }); let confirm_payload = json!({ "did": did, diff --git a/tests/did_web.rs b/tests/did_web.rs index 194578e..79f7e32 100644 --- a/tests/did_web.rs +++ b/tests/did_web.rs @@ -1,6 +1,6 @@ mod common; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use common::*; use k256::ecdsa::{SigningKey, signature::Signer}; use reqwest::StatusCode; @@ -387,13 +387,14 @@ async fn test_did_web_byod_flow() { let mock_uri = mock_server.uri(); let mock_addr = mock_uri.trim_start_matches("http://"); let unique_id = uuid::Uuid::new_v4().to_string().replace("-", ""); - let did = format!("did:web:{}:byod:{}", mock_addr.replace(":", "%3A"), unique_id); + let did = format!( + "did:web:{}:byod:{}", + mock_addr.replace(":", "%3A"), + unique_id + ); let handle = format!("byod_{}", uuid::Uuid::new_v4()); let pds_endpoint = base_url().await.replace("http://", "https://"); - let pds_did = format!( - "did:web:{}", - pds_endpoint.trim_start_matches("https://") - ); + let pds_did = format!("did:web:{}", pds_endpoint.trim_start_matches("https://")); let temp_key = SigningKey::random(&mut rand::thread_rng()); let public_key_multibase = signing_key_to_multibase(&temp_key); @@ -443,16 +444,19 @@ async fn test_did_web_byod_flow() { let body: Value = res.json().await.expect("Response was not JSON"); let returned_did = body["did"].as_str().expect("No DID in response"); assert_eq!(returned_did, did, "Returned DID should match requested DID"); - let access_jwt = body["accessJwt"] - .as_str() - .expect("No accessJwt in response"); + assert_eq!( + body["verificationRequired"], true, + "BYOD accounts should require verification" + ); + + let access_jwt = common::verify_new_account(&client, returned_did).await; let res = client .get(format!( "{}/xrpc/com.atproto.server.checkAccountStatus", base_url().await )) - .bearer_auth(access_jwt) + .bearer_auth(&access_jwt) .send() .await .expect("Failed to check account status"); @@ -468,7 +472,7 @@ async fn test_did_web_byod_flow() { "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials", base_url().await )) - .bearer_auth(access_jwt) + .bearer_auth(&access_jwt) .send() .await .expect("Failed to get recommended credentials"); @@ -491,7 +495,7 @@ async fn test_did_web_byod_flow() { "{}/xrpc/com.atproto.server.activateAccount", base_url().await )) - .bearer_auth(access_jwt) + .bearer_auth(&access_jwt) .send() .await .expect("Failed to activate account"); @@ -506,7 +510,7 @@ async fn test_did_web_byod_flow() { "{}/xrpc/com.atproto.server.checkAccountStatus", base_url().await )) - .bearer_auth(access_jwt) + .bearer_auth(&access_jwt) .send() .await .expect("Failed to check account status"); @@ -522,7 +526,7 @@ async fn test_did_web_byod_flow() { "{}/xrpc/com.atproto.repo.createRecord", base_url().await )) - .bearer_auth(access_jwt) + .bearer_auth(&access_jwt) .json(&json!({ "repo": did, "collection": "app.bsky.feed.post", diff --git a/tests/email_update.rs b/tests/email_update.rs index 00bba9c..6ae0c0c 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -12,6 +12,30 @@ async fn get_pool() -> PgPool { .expect("Failed to connect to test database") } +async fn get_email_update_token(pool: &PgPool, did: &str) -> String { + let body_text: String = sqlx::query_scalar!( + "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", + did + ) + .fetch_one(pool) + .await + .expect("Verification not found"); + + body_text + .lines() + .skip_while(|line| !line.contains("verification code")) + .nth(1) + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty() && line.contains('-')) + .unwrap_or_else(|| { + body_text + .lines() + .find(|line| line.trim().starts_with("MX") && line.contains('-')) + .map(|s| s.trim().to_string()) + .unwrap_or_default() + }) +} + async fn create_verified_account( client: &reqwest::Client, base_url: &str, @@ -61,19 +85,7 @@ async fn test_email_update_flow_success() { let body: Value = res.json().await.expect("Invalid JSON"); assert_eq!(body["tokenRequired"], true); - let verification = sqlx::query!( - "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", - did - ) - .fetch_one(&pool) - .await - .expect("Verification not found"); - - assert_eq!( - verification.pending_identifier.as_deref(), - Some(new_email.as_str()) - ); - let code = verification.code; + let code = get_email_update_token(&pool, &did).await; let res = client .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) .bearer_auth(&access_jwt) @@ -90,15 +102,6 @@ async fn test_email_update_flow_success() { .await .expect("User not found"); assert_eq!(user.email, Some(new_email)); - - let verification = sqlx::query!( - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", - did - ) - .fetch_optional(&pool) - .await - .expect("DB error"); - assert!(verification.is_none()); } #[tokio::test] @@ -180,14 +183,7 @@ async fn test_confirm_email_wrong_email() { .await .expect("Failed to request email update"); assert_eq!(res.status(), StatusCode::OK); - let verification = sqlx::query!( - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", - did - ) - .fetch_one(&pool) - .await - .expect("Verification not found"); - let code = verification.code; + let code = get_email_update_token(&pool, &did).await; let res = client .post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url)) .bearer_auth(&access_jwt) @@ -200,17 +196,18 @@ async fn test_confirm_email_wrong_email() { .expect("Failed to confirm email"); assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: Value = res.json().await.expect("Invalid JSON"); - assert_eq!(body["message"], "Email does not match pending update"); + assert!( + body["message"].as_str().unwrap().contains("mismatch") || body["error"] == "InvalidToken" + ); } #[tokio::test] -async fn test_update_email_success_no_token_required() { +async fn test_update_email_requires_token() { let client = common::client(); let base_url = common::base_url().await; - let pool = get_pool().await; let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); - let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; let new_email = format!("direct_{}@example.com", handle); let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) @@ -219,12 +216,9 @@ async fn test_update_email_success_no_token_required() { .send() .await .expect("Failed to update email"); - assert_eq!(res.status(), StatusCode::OK); - let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) - .fetch_one(&pool) - .await - .expect("User not found"); - assert_eq!(user.email, Some(new_email)); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.expect("Invalid JSON"); + assert_eq!(body["error"], "TokenRequired"); } #[tokio::test] @@ -299,14 +293,7 @@ async fn test_update_email_with_valid_token() { .await .expect("Failed to request email update"); assert_eq!(res.status(), StatusCode::OK); - let verification = sqlx::query!( - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", - did - ) - .fetch_one(&pool) - .await - .expect("Verification not found"); - let code = verification.code; + let code = get_email_update_token(&pool, &did).await; let res = client .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) .bearer_auth(&access_jwt) @@ -323,14 +310,6 @@ async fn test_update_email_with_valid_token() { .await .expect("User not found"); assert_eq!(user.email, Some(new_email)); - let verification = sqlx::query!( - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", - did - ) - .fetch_optional(&pool) - .await - .expect("DB error"); - assert!(verification.is_none()); } #[tokio::test] @@ -387,7 +366,11 @@ async fn test_update_email_already_taken() { assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: Value = res.json().await.expect("Invalid JSON"); assert!( - body["message"].as_str().unwrap().contains("already in use") + body["error"] == "TokenRequired" + || body["message"] + .as_str() + .unwrap_or("") + .contains("already in use") || body["error"] == "InvalidRequest" ); } diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs index c8ec477..5c885e0 100644 --- a/tests/jwt_security.rs +++ b/tests/jwt_security.rs @@ -688,10 +688,29 @@ async fn test_refresh_token_replay_protection() { .connect(&get_db_connection_string().await) .await .unwrap(); - let code: String = sqlx::query_scalar!( - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", + let body_text: String = sqlx::query_scalar!( + "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", did ).fetch_one(&pool).await.unwrap(); + let code = body_text + .lines() + .find(|line| line.contains("verification code:") || line.contains("code is:")) + .and_then(|line| { + if line.contains("verification code:") { + line.split("verification code:") + .nth(1) + .map(|s| s.trim().to_string()) + } else { + line.split("code is:").nth(1).map(|s| s.trim().to_string()) + } + }) + .unwrap_or_else(|| { + body_text + .lines() + .find(|line| line.trim().starts_with("MX") && line.contains('-')) + .map(|s| s.trim().to_string()) + .unwrap_or_default() + }); let confirm = http_client .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))