Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

feat: legacy 2fa implementation #8

merged opened by lewis.moe targeting main from feat/bsky-2fa-shim

I think it would be nice to have the legacy 2fa available for fun.

Labels

None yet.

assignee

None yet.

Participants 2
Referenced by
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mdikn4nj6h22
+1991 -74
Diff #1
+112
.sqlx/query-297fcbb356d65aae3faae5430000b6c6fbec8566a4adbb595c91606fdfa3bedc.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT handle, email, email_verified, is_admin, deactivated_at, takedown_ref,\n preferred_locale,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n discord_verified, telegram_verified, signal_verified,\n migrated_to_pds, migrated_at,\n (SELECT verified FROM user_totp WHERE did = users.did) as totp_enabled\n FROM users\n WHERE did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "handle", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email_verified", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "is_admin", 24 + "type_info": "Bool" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "deactivated_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "takedown_ref", 34 + "type_info": "Text" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "preferred_locale", 39 + "type_info": "Varchar" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "preferred_comms_channel!: CommsChannel", 44 + "type_info": { 45 + "Custom": { 46 + "name": "comms_channel", 47 + "kind": { 48 + "Enum": [ 49 + "email", 50 + "discord", 51 + "telegram", 52 + "signal" 53 + ] 54 + } 55 + } 56 + } 57 + }, 58 + { 59 + "ordinal": 8, 60 + "name": "discord_verified", 61 + "type_info": "Bool" 62 + }, 63 + { 64 + "ordinal": 9, 65 + "name": "telegram_verified", 66 + "type_info": "Bool" 67 + }, 68 + { 69 + "ordinal": 10, 70 + "name": "signal_verified", 71 + "type_info": "Bool" 72 + }, 73 + { 74 + "ordinal": 11, 75 + "name": "migrated_to_pds", 76 + "type_info": "Text" 77 + }, 78 + { 79 + "ordinal": 12, 80 + "name": "migrated_at", 81 + "type_info": "Timestamptz" 82 + }, 83 + { 84 + "ordinal": 13, 85 + "name": "totp_enabled", 86 + "type_info": "Bool" 87 + } 88 + ], 89 + "parameters": { 90 + "Left": [ 91 + "Text" 92 + ] 93 + }, 94 + "nullable": [ 95 + false, 96 + true, 97 + false, 98 + false, 99 + true, 100 + true, 101 + true, 102 + false, 103 + false, 104 + false, 105 + false, 106 + true, 107 + true, 108 + null 109 + ] 110 + }, 111 + "hash": "297fcbb356d65aae3faae5430000b6c6fbec8566a4adbb595c91606fdfa3bedc" 112 + }
+15
.sqlx/query-3b056b9e79847c8bbb8507f283213e7209b417e7933f5b2277a83cae7e1c7888.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM account_preferences WHERE user_id = $1 AND name = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "3b056b9e79847c8bbb8507f283213e7209b417e7933f5b2277a83cae7e1c7888" 15 + }
+136
.sqlx/query-a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "handle", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "password_hash", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "email", 29 + "type_info": "Text" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "deactivated_at", 34 + "type_info": "Timestamptz" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "takedown_ref", 39 + "type_info": "Text" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "email_verified", 44 + "type_info": "Bool" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "discord_verified", 49 + "type_info": "Bool" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "telegram_verified", 54 + "type_info": "Bool" 55 + }, 56 + { 57 + "ordinal": 10, 58 + "name": "signal_verified", 59 + "type_info": "Bool" 60 + }, 61 + { 62 + "ordinal": 11, 63 + "name": "allow_legacy_login", 64 + "type_info": "Bool" 65 + }, 66 + { 67 + "ordinal": 12, 68 + "name": "migrated_to_pds", 69 + "type_info": "Text" 70 + }, 71 + { 72 + "ordinal": 13, 73 + "name": "preferred_comms_channel: CommsChannel", 74 + "type_info": { 75 + "Custom": { 76 + "name": "comms_channel", 77 + "kind": { 78 + "Enum": [ 79 + "email", 80 + "discord", 81 + "telegram", 82 + "signal" 83 + ] 84 + } 85 + } 86 + } 87 + }, 88 + { 89 + "ordinal": 14, 90 + "name": "key_bytes", 91 + "type_info": "Bytea" 92 + }, 93 + { 94 + "ordinal": 15, 95 + "name": "encryption_version", 96 + "type_info": "Int4" 97 + }, 98 + { 99 + "ordinal": 16, 100 + "name": "totp_enabled", 101 + "type_info": "Bool" 102 + }, 103 + { 104 + "ordinal": 17, 105 + "name": "email_2fa_enabled!", 106 + "type_info": "Bool" 107 + } 108 + ], 109 + "parameters": { 110 + "Left": [ 111 + "Text" 112 + ] 113 + }, 114 + "nullable": [ 115 + false, 116 + false, 117 + false, 118 + true, 119 + true, 120 + true, 121 + true, 122 + false, 123 + false, 124 + false, 125 + false, 126 + false, 127 + true, 128 + false, 129 + false, 130 + true, 131 + null, 132 + null 133 + ] 134 + }, 135 + "hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249" 136 + }
+118
.sqlx/query-c8728a1247c535e941e2b3bcb4100d7b3610f31c7acfdc1f8c072e1c5ca0ea18.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT u.handle, u.email, u.email_verified, u.is_admin, u.deactivated_at, u.takedown_ref,\n u.preferred_locale,\n u.preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n u.discord_verified, u.telegram_verified, u.signal_verified,\n u.migrated_to_pds, u.migrated_at,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n WHERE u.did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "handle", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "email", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "email_verified", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "is_admin", 24 + "type_info": "Bool" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "deactivated_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "takedown_ref", 34 + "type_info": "Text" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "preferred_locale", 39 + "type_info": "Varchar" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "preferred_comms_channel!: CommsChannel", 44 + "type_info": { 45 + "Custom": { 46 + "name": "comms_channel", 47 + "kind": { 48 + "Enum": [ 49 + "email", 50 + "discord", 51 + "telegram", 52 + "signal" 53 + ] 54 + } 55 + } 56 + } 57 + }, 58 + { 59 + "ordinal": 8, 60 + "name": "discord_verified", 61 + "type_info": "Bool" 62 + }, 63 + { 64 + "ordinal": 9, 65 + "name": "telegram_verified", 66 + "type_info": "Bool" 67 + }, 68 + { 69 + "ordinal": 10, 70 + "name": "signal_verified", 71 + "type_info": "Bool" 72 + }, 73 + { 74 + "ordinal": 11, 75 + "name": "migrated_to_pds", 76 + "type_info": "Text" 77 + }, 78 + { 79 + "ordinal": 12, 80 + "name": "migrated_at", 81 + "type_info": "Timestamptz" 82 + }, 83 + { 84 + "ordinal": 13, 85 + "name": "totp_enabled", 86 + "type_info": "Bool" 87 + }, 88 + { 89 + "ordinal": 14, 90 + "name": "email_2fa_enabled!", 91 + "type_info": "Bool" 92 + } 93 + ], 94 + "parameters": { 95 + "Left": [ 96 + "Text" 97 + ] 98 + }, 99 + "nullable": [ 100 + false, 101 + true, 102 + false, 103 + false, 104 + true, 105 + true, 106 + true, 107 + false, 108 + false, 109 + false, 110 + false, 111 + true, 112 + true, 113 + null, 114 + null 115 + ] 116 + }, 117 + "hash": "c8728a1247c535e941e2b3bcb4100d7b3610f31c7acfdc1f8c072e1c5ca0ea18" 118 + }
+4
crates/tranquil-cache/src/lib.rs
··· 91 91 async fn set_bytes(&self, _key: &str, _value: &[u8], _ttl: Duration) -> Result<(), CacheError> { 92 92 Ok(()) 93 93 } 94 + 95 + fn is_available(&self) -> bool { 96 + false 97 + } 94 98 } 95 99 96 100 #[derive(Clone)]
+7
crates/tranquil-comms/src/locale.rs
··· 16 16 pub password_reset_body: &'static str, 17 17 pub email_update_subject: &'static str, 18 18 pub email_update_body: &'static str, 19 + pub short_token_body: &'static str, 19 20 pub account_deletion_subject: &'static str, 20 21 pub account_deletion_body: &'static str, 21 22 pub plc_operation_subject: &'static str, ··· 50 51 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.", 51 52 email_update_subject: "Confirm your new email - {hostname}", 52 53 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\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not request this, please ignore this email.", 54 + short_token_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nThis code will expire in 15 minutes.\n\nIf you did not request this, please ignore this email.", 53 55 account_deletion_subject: "Account Deletion Request - {hostname}", 54 56 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.", 55 57 plc_operation_subject: "{hostname} - PLC Operation Token", ··· 73 75 password_reset_body: "ๆ‚จๅฅฝ @{handle}๏ผŒ\n\nๆ‚จ็š„ๅฏ†็ ้‡็ฝฎ้ชŒ่ฏ็ ๆ˜ฏ๏ผš{code}\n\nๆญค้ชŒ่ฏ็ ๅฐ†ๅœจ10ๅˆ†้’ŸๅŽ่ฟ‡ๆœŸใ€‚\n\nๅฆ‚ๆžœ่ฟ™ไธๆ˜ฏๆ‚จ็š„ๆ“ไฝœ๏ผŒ่ฏทๅฟฝ็•ฅๆญคๆถˆๆฏใ€‚", 74 76 email_update_subject: "็กฎ่ฎคๆ‚จ็š„ๆ–ฐ้‚ฎ็ฎฑ - {hostname}", 75 77 email_update_body: "ๆ‚จๅฅฝ @{handle}๏ผŒ\n\nๆ‚จ็š„้ชŒ่ฏ็ ๆ˜ฏ๏ผš\n{code}\n\nๅคๅˆถไธŠ่ฟฐ้ชŒ่ฏ็ ๅนถๅœจๆญค่พ“ๅ…ฅ๏ผš\n{verify_page}\n\nๆญค้ชŒ่ฏ็ ๅฐ†ๅœจ10ๅˆ†้’ŸๅŽ่ฟ‡ๆœŸใ€‚\n\nๆˆ–่€…็›ดๆŽฅ็‚นๅ‡ป้“พๆŽฅ๏ผš\n{verify_link}\n\nๅฆ‚ๆžœ่ฟ™ไธๆ˜ฏๆ‚จ็š„ๆ“ไฝœ๏ผŒ่ฏทๅฟฝ็•ฅๆญค้‚ฎไปถใ€‚", 78 + short_token_body: "ๆ‚จๅฅฝ @{handle}๏ผŒ\n\nๆ‚จ็š„้ชŒ่ฏ็ ๆ˜ฏ๏ผš\n{code}\n\nๆญค้ชŒ่ฏ็ ๅฐ†ๅœจ15ๅˆ†้’ŸๅŽ่ฟ‡ๆœŸใ€‚\n\nๅฆ‚ๆžœ่ฟ™ไธๆ˜ฏๆ‚จ็š„ๆ“ไฝœ๏ผŒ่ฏทๅฟฝ็•ฅๆญค้‚ฎไปถใ€‚", 76 79 account_deletion_subject: "่ดฆๆˆทๅˆ ้™ค่ฏทๆฑ‚ - {hostname}", 77 80 account_deletion_body: "ๆ‚จๅฅฝ @{handle}๏ผŒ\n\nๆ‚จ็š„่ดฆๆˆทๅˆ ้™ค็กฎ่ฎค็ ๆ˜ฏ๏ผš{code}\n\nๆญค้ชŒ่ฏ็ ๅฐ†ๅœจ10ๅˆ†้’ŸๅŽ่ฟ‡ๆœŸใ€‚\n\nๅฆ‚ๆžœ่ฟ™ไธๆ˜ฏๆ‚จ็š„ๆ“ไฝœ๏ผŒ่ฏท็ซ‹ๅณไฟๆŠคๆ‚จ็š„่ดฆๆˆทใ€‚", 78 81 plc_operation_subject: "{hostname} - PLC ๆ“ไฝœไปค็‰Œ", ··· 96 99 password_reset_body: "@{handle} ๆง˜\n\nใƒ‘ใ‚นใƒฏใƒผใƒ‰ใƒชใ‚ปใƒƒใƒˆใ‚ณใƒผใƒ‰ใฏ๏ผš{code}\n\nใ“ใฎใ‚ณใƒผใƒ‰ใฏ10ๅˆ†ๅพŒใซๆœŸ้™ๅˆ‡ใ‚Œใจใชใ‚Šใพใ™ใ€‚\n\nใ“ใฎๆ“ไฝœใซๅฟƒๅฝ“ใŸใ‚ŠใŒใชใ„ๅ ดๅˆใฏใ€ใ“ใฎใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’็„ก่ฆ–ใ—ใฆใใ ใ•ใ„ใ€‚", 97 100 email_update_subject: "ๆ–ฐใ—ใ„ใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใฎ็ขบ่ช - {hostname}", 98 101 email_update_body: "@{handle} ๆง˜\n\n็ขบ่ชใ‚ณใƒผใƒ‰ใฏ๏ผš\n{code}\n\nไธŠ่จ˜ใฎใ‚ณใƒผใƒ‰ใ‚’ใ‚ณใƒ”ใƒผใ—ใฆใ€ใ“ใกใ‚‰ใงๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„๏ผš\n{verify_page}\n\nใ“ใฎใ‚ณใƒผใƒ‰ใฏ10ๅˆ†ๅพŒใซๆœŸ้™ๅˆ‡ใ‚Œใจใชใ‚Šใพใ™ใ€‚\n\n่‡ชๅทฑ่ฒฌไปปใงใƒฏใƒณใ‚ฏใƒชใƒƒใ‚ฏ่ช่จผ๏ผš\n{verify_link}\n\nใ“ใฎๆ“ไฝœใซๅฟƒๅฝ“ใŸใ‚ŠใŒใชใ„ๅ ดๅˆใฏใ€ใ“ใฎใƒกใƒผใƒซใ‚’็„ก่ฆ–ใ—ใฆใใ ใ•ใ„ใ€‚", 102 + short_token_body: "@{handle} ๆง˜\n\n็ขบ่ชใ‚ณใƒผใƒ‰ใฏ๏ผš\n{code}\n\nใ“ใฎใ‚ณใƒผใƒ‰ใฏ15ๅˆ†ๅพŒใซๆœŸ้™ๅˆ‡ใ‚Œใจใชใ‚Šใพใ™ใ€‚\n\nใ“ใฎๆ“ไฝœใซๅฟƒๅฝ“ใŸใ‚ŠใŒใชใ„ๅ ดๅˆใฏใ€ใ“ใฎใƒกใƒผใƒซใ‚’็„ก่ฆ–ใ—ใฆใใ ใ•ใ„ใ€‚", 99 103 account_deletion_subject: "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆๅ‰Š้™คใƒชใ‚ฏใ‚จใ‚นใƒˆ - {hostname}", 100 104 account_deletion_body: "@{handle} ๆง˜\n\nใ‚ขใ‚ซใ‚ฆใƒณใƒˆๅ‰Š้™คใฎ็ขบ่ชใ‚ณใƒผใƒ‰ใฏ๏ผš{code}\n\nใ“ใฎใ‚ณใƒผใƒ‰ใฏ10ๅˆ†ๅพŒใซๆœŸ้™ๅˆ‡ใ‚Œใจใชใ‚Šใพใ™ใ€‚\n\nใ“ใฎๆ“ไฝœใซๅฟƒๅฝ“ใŸใ‚ŠใŒใชใ„ๅ ดๅˆใฏใ€็›ดใกใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฟ่ญทใ—ใฆใใ ใ•ใ„ใ€‚", 101 105 plc_operation_subject: "{hostname} - PLC ๆ“ไฝœใƒˆใƒผใ‚ฏใƒณ", ··· 119 123 password_reset_body: "์•ˆ๋…•ํ•˜์„ธ์š” @{handle}๋‹˜,\n\n๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์„ค์ • ์ฝ”๋“œ๋Š”: {code}\n\n์ด ์ฝ”๋“œ๋Š” 10๋ถ„ ํ›„์— ๋งŒ๋ฃŒ๋ฉ๋‹ˆ๋‹ค.\n\n์š”์ฒญํ•˜์ง€ ์•Š์œผ์…จ๋‹ค๋ฉด ์ด ๋ฉ”์‹œ์ง€๋ฅผ ๋ฌด์‹œํ•˜์„ธ์š”.", 120 124 email_update_subject: "์ƒˆ ์ด๋ฉ”์ผ ์ฃผ์†Œ ํ™•์ธ - {hostname}", 121 125 email_update_body: "์•ˆ๋…•ํ•˜์„ธ์š” @{handle}๋‹˜,\n\n์ธ์ฆ ์ฝ”๋“œ๋Š”:\n{code}\n\n์œ„ ์ฝ”๋“œ๋ฅผ ๋ณต์‚ฌํ•˜์—ฌ ์—ฌ๊ธฐ์— ์ž…๋ ฅํ•˜์„ธ์š”:\n{verify_page}\n\n์ด ์ฝ”๋“œ๋Š” 10๋ถ„ ํ›„์— ๋งŒ๋ฃŒ๋ฉ๋‹ˆ๋‹ค.\n\n์œ„ํ—˜์„ ๊ฐ์ˆ˜ํ•˜๊ณ  ์›ํด๋ฆญ ์ธ์ฆ:\n{verify_link}\n\n์š”์ฒญํ•˜์ง€ ์•Š์œผ์…จ๋‹ค๋ฉด ์ด ์ด๋ฉ”์ผ์„ ๋ฌด์‹œํ•˜์„ธ์š”.", 126 + short_token_body: "์•ˆ๋…•ํ•˜์„ธ์š” @{handle}๋‹˜,\n\n์ธ์ฆ ์ฝ”๋“œ๋Š”:\n{code}\n\n์ด ์ฝ”๋“œ๋Š” 15๋ถ„ ํ›„์— ๋งŒ๋ฃŒ๋ฉ๋‹ˆ๋‹ค.\n\n์š”์ฒญํ•˜์ง€ ์•Š์œผ์…จ๋‹ค๋ฉด ์ด ์ด๋ฉ”์ผ์„ ๋ฌด์‹œํ•˜์„ธ์š”.", 122 127 account_deletion_subject: "๊ณ„์ • ์‚ญ์ œ ์š”์ฒญ - {hostname}", 123 128 account_deletion_body: "์•ˆ๋…•ํ•˜์„ธ์š” @{handle}๋‹˜,\n\n๊ณ„์ • ์‚ญ์ œ ํ™•์ธ ์ฝ”๋“œ๋Š”: {code}\n\n์ด ์ฝ”๋“œ๋Š” 10๋ถ„ ํ›„์— ๋งŒ๋ฃŒ๋ฉ๋‹ˆ๋‹ค.\n\n์š”์ฒญํ•˜์ง€ ์•Š์œผ์…จ๋‹ค๋ฉด ์ฆ‰์‹œ ๊ณ„์ •์„ ๋ณดํ˜ธํ•˜์„ธ์š”.", 124 129 plc_operation_subject: "{hostname} - PLC ์ž‘์—… ํ† ํฐ", ··· 142 147 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.", 143 148 email_update_subject: "Bekrรคfta din nya e-post - {hostname}", 144 149 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\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte begรคrde detta kan du ignorera detta meddelande.", 150 + short_token_body: "Hej @{handle},\n\nDin verifieringskod รคr:\n{code}\n\nDenna kod upphรถr om 15 minuter.\n\nOm du inte begรคrde detta kan du ignorera detta meddelande.", 145 151 account_deletion_subject: "Begรคran om kontoradering - {hostname}", 146 152 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.", 147 153 plc_operation_subject: "{hostname} - PLC-operationstoken", ··· 165 171 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.", 166 172 email_update_subject: "Vahvista uusi sรคhkรถpostisi - {hostname}", 167 173 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\nTai jos pidรคt vaarallisesta elรคmรคstรค:\n{verify_link}\n\nJos et pyytรคnyt tรคtรค, voit jรคttรครค tรคmรคn viestin huomiotta.", 174 + short_token_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nTรคmรค koodi vanhenee 15 minuutissa.\n\nJos et pyytรคnyt tรคtรค, voit jรคttรครค tรคmรคn viestin huomiotta.", 168 175 account_deletion_subject: "Tilin poistopyyntรถ - {hostname}", 169 176 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.", 170 177 plc_operation_subject: "{hostname} - PLC-toimintotunniste",
+3
crates/tranquil-db-traits/src/user.rs
··· 768 768 pub channel_verification: ChannelVerificationStatus, 769 769 pub migrated_to_pds: Option<String>, 770 770 pub migrated_at: Option<DateTime<Utc>>, 771 + pub totp_enabled: bool, 772 + pub email_2fa_enabled: bool, 771 773 } 772 774 773 775 #[derive(Debug, Clone)] ··· 792 794 pub key_bytes: Vec<u8>, 793 795 pub encryption_version: Option<i32>, 794 796 pub totp_enabled: bool, 797 + pub email_2fa_enabled: bool, 795 798 } 796 799 797 800 #[derive(Debug, Clone)]
+15 -3
crates/tranquil-db/src/postgres/infra.rs
··· 661 661 name: &str, 662 662 value_json: serde_json::Value, 663 663 ) -> Result<(), DbError> { 664 + let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; 665 + 664 666 sqlx::query!( 665 - r#"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) 666 - ON CONFLICT (user_id, name) DO UPDATE SET value_json = $3"#, 667 + r#"DELETE FROM account_preferences WHERE user_id = $1 AND name = $2"#, 668 + user_id, 669 + name 670 + ) 671 + .execute(&mut *tx) 672 + .await 673 + .map_err(map_sqlx_error)?; 674 + 675 + sqlx::query!( 676 + r#"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)"#, 667 677 user_id, 668 678 name, 669 679 value_json 670 680 ) 671 - .execute(&self.pool) 681 + .execute(&mut *tx) 672 682 .await 673 683 .map_err(map_sqlx_error)?; 674 684 685 + tx.commit().await.map_err(map_sqlx_error)?; 686 + 675 687 Ok(()) 676 688 } 677 689
+14 -8
crates/tranquil-db/src/postgres/user.rs
··· 1374 1374 async fn get_session_info_by_did(&self, did: &Did) -> Result<Option<UserSessionInfo>, DbError> { 1375 1375 sqlx::query!( 1376 1376 r#" 1377 - SELECT handle, email, email_verified, is_admin, deactivated_at, takedown_ref, 1378 - preferred_locale, 1379 - preferred_comms_channel as "preferred_comms_channel!: CommsChannel", 1380 - discord_verified, telegram_verified, signal_verified, 1381 - migrated_to_pds, migrated_at 1382 - FROM users 1383 - WHERE did = $1 1377 + SELECT u.handle, u.email, u.email_verified, u.is_admin, u.deactivated_at, u.takedown_ref, 1378 + u.preferred_locale, 1379 + u.preferred_comms_channel as "preferred_comms_channel!: CommsChannel", 1380 + u.discord_verified, u.telegram_verified, u.signal_verified, 1381 + u.migrated_to_pds, u.migrated_at, 1382 + (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled, 1383 + COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!" 1384 + FROM users u 1385 + WHERE u.did = $1 1384 1386 "#, 1385 1387 did.as_str() 1386 1388 ) ··· 1404 1406 ), 1405 1407 migrated_to_pds: row.migrated_to_pds, 1406 1408 migrated_at: row.migrated_at, 1409 + totp_enabled: row.totp_enabled.unwrap_or(false), 1410 + email_2fa_enabled: row.email_2fa_enabled, 1407 1411 }) 1408 1412 }) 1409 1413 } ··· 1468 1472 u.allow_legacy_login, u.migrated_to_pds, 1469 1473 u.preferred_comms_channel as "preferred_comms_channel: CommsChannel", 1470 1474 k.key_bytes, k.encryption_version, 1471 - (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled 1475 + (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled, 1476 + COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!" 1472 1477 FROM users u 1473 1478 JOIN user_keys k ON u.id = k.user_id 1474 1479 WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, ··· 1498 1503 key_bytes: row.key_bytes, 1499 1504 encryption_version: row.encryption_version, 1500 1505 totp_enabled: row.totp_enabled.unwrap_or(false), 1506 + email_2fa_enabled: row.email_2fa_enabled, 1501 1507 }) 1502 1508 }) 1503 1509 }
+3
crates/tranquil-infra/src/lib.rs
··· 73 73 async fn delete(&self, key: &str) -> Result<(), CacheError>; 74 74 async fn get_bytes(&self, key: &str) -> Option<Vec<u8>>; 75 75 async fn set_bytes(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError>; 76 + fn is_available(&self) -> bool { 77 + true 78 + } 76 79 } 77 80 78 81 #[async_trait]
+14 -2
crates/tranquil-pds/src/api/error.rs
··· 114 114 SsoSessionExpired, 115 115 SsoAlreadyLinked, 116 116 SsoLinkNotFound, 117 + AuthFactorTokenRequired, 118 + LegacyLoginBlocked, 117 119 } 118 120 119 121 impl ApiError { ··· 132 134 | Self::AuthenticationFailed(_) 133 135 | Self::AccountDeactivated 134 136 | Self::AccountTakedown 135 - | Self::InvalidCode(_) 136 137 | Self::InvalidPassword(_) 137 138 | Self::InvalidToken(_) 138 139 | Self::PasskeyCounterAnomaly 139 140 | Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED, 141 + Self::InvalidCode(_) => StatusCode::BAD_REQUEST, 140 142 Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, 141 143 Self::Forbidden 142 144 | Self::AdminRequired ··· 210 212 | Self::SsoInvalidAction 211 213 | Self::SsoNotAuthenticated 212 214 | Self::SsoSessionExpired 213 - | Self::SsoAlreadyLinked => StatusCode::BAD_REQUEST, 215 + | Self::SsoAlreadyLinked 216 + | Self::AuthFactorTokenRequired 217 + | Self::LegacyLoginBlocked => StatusCode::BAD_REQUEST, 214 218 Self::PasskeyNotFound | Self::SsoLinkNotFound => StatusCode::NOT_FOUND, 215 219 } 216 220 } ··· 313 317 Self::SsoSessionExpired => Cow::Borrowed("SsoSessionExpired"), 314 318 Self::SsoAlreadyLinked => Cow::Borrowed("SsoAlreadyLinked"), 315 319 Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"), 320 + Self::AuthFactorTokenRequired => Cow::Borrowed("AuthFactorTokenRequired"), 321 + Self::LegacyLoginBlocked => Cow::Borrowed("MfaRequired"), 316 322 } 317 323 } 318 324 fn message(&self) -> Option<String> { ··· 436 442 Self::InvalidEmail => Some("Please provide a valid email address".to_string()), 437 443 Self::InvalidInviteCode => Some("The invite code provided is invalid".to_string()), 438 444 Self::DuplicateCreate => Some("Account creation failed: duplicate request".to_string()), 445 + Self::LegacyLoginBlocked => Some( 446 + "This account requires MFA. Please use an OAuth client that supports TOTP verification.".to_string(), 447 + ), 448 + Self::AuthFactorTokenRequired => { 449 + Some("A sign in code has been sent to your email address".to_string()) 450 + } 439 451 _ => None, 440 452 } 441 453 }
+102 -40
crates/tranquil-pds/src/api/server/email.rs
··· 66 66 .log_db_err("getting email info")? 67 67 .ok_or(ApiError::AccountNotFound)?; 68 68 69 - let Some(current_email) = user.email else { 69 + let Some(_current_email) = user.email else { 70 70 return Err(ApiError::InvalidRequest( 71 71 "account does not have an email address".into(), 72 72 )); ··· 75 75 let token_required = user.email_verified; 76 76 77 77 if token_required { 78 - let code = crate::auth::verification_token::generate_channel_update_token( 79 - &auth.did, 80 - "email_update", 81 - &current_email.to_lowercase(), 82 - ); 83 - let formatted_code = crate::auth::verification_token::format_token_for_display(&code); 78 + let token = crate::auth::email_token::create_email_token( 79 + state.cache.as_ref(), 80 + auth.did.as_str(), 81 + crate::auth::email_token::EmailTokenPurpose::UpdateEmail, 82 + ) 83 + .await 84 + .map_err(|e| { 85 + error!("Failed to create email update token: {:?}", e); 86 + ApiError::InternalError(Some("Failed to generate verification code".into())) 87 + })?; 84 88 85 89 if let Some(Json(ref inp)) = input 86 90 && let Some(ref new_email) = inp.new_email ··· 89 93 if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) { 90 94 let pending = PendingEmailUpdate { 91 95 new_email, 92 - token_hash: hash_token(&code), 96 + token_hash: hash_token(&token), 93 97 authorized: false, 94 98 }; 95 99 if let Ok(json) = serde_json::to_string(&pending) { ··· 102 106 } 103 107 104 108 let hostname = pds_hostname(); 105 - if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token( 109 + if let Err(e) = crate::comms::comms_repo::enqueue_short_token_email( 106 110 state.user_repo.as_ref(), 107 111 state.infra_repo.as_ref(), 108 112 user.id, 109 - &code, 110 - &formatted_code, 113 + &token, 114 + "email_update", 111 115 hostname, 112 116 ) 113 117 .await ··· 239 243 )); 240 244 } 241 245 242 - if let Some(ref current) = current_email 243 - && new_email == current.to_lowercase() 244 - { 246 + let email_unchanged = current_email 247 + .as_ref() 248 + .map(|c| new_email == c.to_lowercase()) 249 + .unwrap_or(false); 250 + 251 + if email_unchanged { 252 + if let Some(email_auth_factor) = input.email_auth_factor { 253 + if email_verified { 254 + let token = input 255 + .token 256 + .as_ref() 257 + .filter(|t| !t.is_empty()) 258 + .ok_or(ApiError::TokenRequired)?; 259 + 260 + crate::auth::email_token::validate_email_token( 261 + state.cache.as_ref(), 262 + did.as_str(), 263 + crate::auth::email_token::EmailTokenPurpose::UpdateEmail, 264 + token, 265 + ) 266 + .await 267 + .map_err(|e| match e { 268 + crate::auth::email_token::TokenError::ExpiredToken => { 269 + ApiError::ExpiredToken(None) 270 + } 271 + _ => ApiError::InvalidToken(None), 272 + })?; 273 + } 274 + 275 + state 276 + .infra_repo 277 + .upsert_account_preference(user_id, "email_auth_factor", json!(email_auth_factor)) 278 + .await 279 + .map_err(|e| { 280 + error!("Failed to update email_auth_factor preference: {}", e); 281 + ApiError::InternalError(Some("Failed to update 2FA setting".into())) 282 + })?; 283 + } 245 284 return Ok(EmptyResponse::ok().into_response()); 246 285 } 247 286 ··· 260 299 } 261 300 262 301 if !authorized_via_link { 263 - let Some(ref t) = input.token else { 264 - return Err(ApiError::TokenRequired); 265 - }; 266 - let confirmation_token = 267 - crate::auth::verification_token::normalize_token_input(t.trim()); 268 - 269 - let current_email_lower = current_email 302 + let token = input 303 + .token 270 304 .as_ref() 271 - .map(|e| e.to_lowercase()) 272 - .unwrap_or_default(); 273 - 274 - let verified = crate::auth::verification_token::verify_channel_update_token( 275 - &confirmation_token, 276 - "email_update", 277 - &current_email_lower, 278 - ); 279 - 280 - match verified { 281 - Ok(token_data) => { 282 - if token_data.did != did.as_str() { 283 - return Err(ApiError::InvalidToken(None)); 305 + .filter(|t| !t.is_empty()) 306 + .ok_or(ApiError::TokenRequired)?; 307 + 308 + let short_token_result = crate::auth::email_token::validate_email_token( 309 + state.cache.as_ref(), 310 + did.as_str(), 311 + crate::auth::email_token::EmailTokenPurpose::UpdateEmail, 312 + token, 313 + ) 314 + .await; 315 + 316 + if let Err(e) = short_token_result { 317 + let confirmation_token = 318 + crate::auth::verification_token::normalize_token_input(token.trim()); 319 + 320 + let current_email_lower = current_email 321 + .as_ref() 322 + .map(|e| e.to_lowercase()) 323 + .unwrap_or_default(); 324 + 325 + let verified = crate::auth::verification_token::verify_channel_update_token( 326 + &confirmation_token, 327 + "email_update", 328 + &current_email_lower, 329 + ); 330 + 331 + match verified { 332 + Ok(token_data) => { 333 + if token_data.did != did.as_str() { 334 + return Err(ApiError::InvalidToken(None)); 335 + } 336 + } 337 + Err(crate::auth::verification_token::VerifyError::Expired) => { 338 + return Err(match e { 339 + crate::auth::email_token::TokenError::ExpiredToken => { 340 + ApiError::ExpiredToken(None) 341 + } 342 + _ => ApiError::InvalidToken(None), 343 + }); 344 + } 345 + Err(_) => { 346 + return Err(match e { 347 + crate::auth::email_token::TokenError::ExpiredToken => { 348 + ApiError::ExpiredToken(None) 349 + } 350 + _ => ApiError::InvalidToken(None), 351 + }); 284 352 } 285 - } 286 - Err(crate::auth::verification_token::VerifyError::Expired) => { 287 - return Err(ApiError::ExpiredToken(None)); 288 - } 289 - Err(_) => { 290 - return Err(ApiError::InvalidToken(None)); 291 353 } 292 354 } 293 355 }
+88 -12
crates/tranquil-pds/src/api/server/session.rs
··· 32 32 pub password: PlainPassword, 33 33 #[serde(default)] 34 34 pub allow_takendown: bool, 35 + pub auth_factor_token: Option<String>, 35 36 } 36 37 37 38 #[derive(Serialize)] ··· 48 49 #[serde(skip_serializing_if = "Option::is_none")] 49 50 pub email_confirmed: Option<bool>, 50 51 #[serde(skip_serializing_if = "Option::is_none")] 52 + pub email_auth_factor: Option<bool>, 53 + #[serde(skip_serializing_if = "Option::is_none")] 51 54 pub active: Option<bool>, 52 55 #[serde(skip_serializing_if = "Option::is_none")] 53 56 pub status: Option<String>, ··· 158 161 .into_response(); 159 162 } 160 163 let has_totp = row.totp_enabled; 161 - let is_legacy_login = has_totp; 162 - if has_totp && !row.allow_legacy_login { 163 - warn!("Legacy login blocked for TOTP-enabled account: {}", row.did); 164 - return ( 165 - StatusCode::FORBIDDEN, 166 - Json(json!({ 167 - "error": "MfaRequired", 168 - "message": "This account requires MFA. Please use an OAuth client that supports TOTP verification.", 169 - "did": row.did 170 - })), 171 - ) 172 - .into_response(); 164 + let email_2fa_enabled = row.email_2fa_enabled; 165 + let is_legacy_login = has_totp || email_2fa_enabled; 166 + let twofa_ctx = crate::auth::legacy_2fa::Legacy2faContext { 167 + email_2fa_enabled, 168 + has_totp, 169 + allow_legacy_login: row.allow_legacy_login, 170 + }; 171 + match crate::auth::legacy_2fa::process_legacy_2fa( 172 + state.cache.as_ref(), 173 + &row.did, 174 + &twofa_ctx, 175 + input.auth_factor_token.as_deref(), 176 + ) 177 + .await 178 + { 179 + Ok(crate::auth::legacy_2fa::Legacy2faOutcome::NotRequired) => {} 180 + Ok(crate::auth::legacy_2fa::Legacy2faOutcome::Blocked) => { 181 + warn!("Legacy login blocked for TOTP-enabled account: {}", row.did); 182 + return ApiError::LegacyLoginBlocked.into_response(); 183 + } 184 + Ok(crate::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => { 185 + let hostname = pds_hostname(); 186 + if let Err(e) = crate::comms::comms_repo::enqueue_2fa_code( 187 + state.user_repo.as_ref(), 188 + state.infra_repo.as_ref(), 189 + row.id, 190 + code.as_str(), 191 + hostname, 192 + ) 193 + .await 194 + { 195 + error!("Failed to send 2FA code: {:?}", e); 196 + crate::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did).await; 197 + return ApiError::InternalError(Some( 198 + "Failed to send verification code. Please try again.".into(), 199 + )) 200 + .into_response(); 201 + } 202 + return ApiError::AuthFactorTokenRequired.into_response(); 203 + } 204 + Ok(crate::auth::legacy_2fa::Legacy2faOutcome::Verified) => {} 205 + Err(crate::auth::legacy_2fa::Legacy2faFlowError::Challenge(e)) => { 206 + use crate::auth::legacy_2fa::ChallengeError; 207 + return match e { 208 + ChallengeError::CacheUnavailable => { 209 + error!("Cache unavailable for 2FA, blocking legacy login"); 210 + ApiError::ServiceUnavailable(Some( 211 + "2FA service temporarily unavailable. Please try again later or use an OAuth client.".into(), 212 + )) 213 + .into_response() 214 + } 215 + ChallengeError::RateLimited => ApiError::RateLimitExceeded(Some( 216 + "Please wait before requesting a new verification code.".into(), 217 + )) 218 + .into_response(), 219 + ChallengeError::CacheError => { 220 + error!("Cache error during 2FA challenge creation"); 221 + ApiError::InternalError(None).into_response() 222 + } 223 + }; 224 + } 225 + Err(crate::auth::legacy_2fa::Legacy2faFlowError::Validation(e)) => { 226 + use crate::auth::legacy_2fa::ValidationError; 227 + warn!("Invalid 2FA code for {}: {:?}", row.did, e); 228 + let msg = match e { 229 + ValidationError::TooManyAttempts => "Too many attempts. Please request a new code.", 230 + ValidationError::ChallengeExpired => "Code has expired. Please request a new code.", 231 + ValidationError::CacheUnavailable => { 232 + "2FA service temporarily unavailable. Please try again later." 233 + } 234 + ValidationError::ChallengeNotFound 235 + | ValidationError::InvalidCode 236 + | ValidationError::CacheError => "Invalid verification code", 237 + }; 238 + return ApiError::InvalidCode(Some(msg.into())).into_response(); 239 + } 173 240 } 174 241 let access_meta = match crate::auth::create_access_token_with_delegation( 175 242 &row.did, ··· 236 303 let handle = full_handle(&row.handle, pds_host); 237 304 let is_active = account_state.is_active(); 238 305 let status = account_state.status_for_session().map(String::from); 306 + let email_auth_factor_out = if email_2fa_enabled || has_totp { 307 + Some(true) 308 + } else { 309 + None 310 + }; 239 311 Json(CreateSessionOutput { 240 312 access_jwt: access_meta.token, 241 313 refresh_jwt: refresh_meta.token, ··· 244 316 did_doc, 245 317 email: row.email, 246 318 email_confirmed: Some(row.channel_verification.email), 319 + email_auth_factor: email_auth_factor_out, 247 320 active: Some(is_active), 248 321 status, 249 322 }) ··· 301 374 response["email"] = json!(email_value); 302 375 response["emailConfirmed"] = json!(email_confirmed_value); 303 376 } 377 + if row.email_2fa_enabled || row.totp_enabled { 378 + response["emailAuthFactor"] = json!(true); 379 + } 304 380 if let Some(status) = account_state.status_for_session() { 305 381 response["status"] = json!(status); 306 382 }
+2
crates/tranquil-pds/src/api/server/totp.rs
··· 187 187 .await 188 188 .log_db_err("deleting TOTP")?; 189 189 190 + crate::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &auth.did).await; 191 + 190 192 info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method()); 191 193 192 194 Ok(EmptyResponse::ok().into_response())
+303
crates/tranquil-pds/src/auth/email_token.rs
··· 1 + use rand::Rng; 2 + use serde::{Deserialize, Serialize}; 3 + use std::time::Duration; 4 + 5 + use crate::cache::Cache; 6 + 7 + const TOKEN_TTL_SECS: u64 = 900; 8 + const BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; 9 + 10 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 11 + pub enum EmailTokenPurpose { 12 + UpdateEmail, 13 + ConfirmEmail, 14 + DeleteAccount, 15 + ResetPassword, 16 + PlcOperation, 17 + } 18 + 19 + impl EmailTokenPurpose { 20 + fn as_str(&self) -> &'static str { 21 + match self { 22 + Self::UpdateEmail => "update_email", 23 + Self::ConfirmEmail => "confirm_email", 24 + Self::DeleteAccount => "delete_account", 25 + Self::ResetPassword => "reset_password", 26 + Self::PlcOperation => "plc_operation", 27 + } 28 + } 29 + } 30 + 31 + #[derive(Debug, Clone, Serialize, Deserialize)] 32 + struct TokenData { 33 + token: String, 34 + created_at: u64, 35 + } 36 + 37 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 38 + pub enum TokenError { 39 + CacheUnavailable, 40 + CacheError, 41 + InvalidToken, 42 + ExpiredToken, 43 + } 44 + 45 + fn cache_key(did: &str, purpose: EmailTokenPurpose) -> String { 46 + format!("email_token:{}:{}", purpose.as_str(), did) 47 + } 48 + 49 + fn generate_short_token() -> String { 50 + let mut rng = rand::thread_rng(); 51 + let token: String = (0..10) 52 + .map(|_| BASE32_CHARS[rng.gen_range(0..BASE32_CHARS.len())] as char) 53 + .collect(); 54 + format!("{}-{}", &token[0..5], &token[5..10]) 55 + } 56 + 57 + fn current_timestamp() -> u64 { 58 + chrono::Utc::now().timestamp().max(0) as u64 59 + } 60 + 61 + pub async fn create_email_token( 62 + cache: &dyn Cache, 63 + did: &str, 64 + purpose: EmailTokenPurpose, 65 + ) -> Result<String, TokenError> { 66 + if !cache.is_available() { 67 + return Err(TokenError::CacheUnavailable); 68 + } 69 + 70 + let token = generate_short_token(); 71 + let data = TokenData { 72 + token: token.clone(), 73 + created_at: current_timestamp(), 74 + }; 75 + 76 + let json = serde_json::to_string(&data).map_err(|_| TokenError::CacheError)?; 77 + 78 + cache 79 + .set( 80 + &cache_key(did, purpose), 81 + &json, 82 + Duration::from_secs(TOKEN_TTL_SECS), 83 + ) 84 + .await 85 + .map_err(|_| TokenError::CacheError)?; 86 + 87 + Ok(token) 88 + } 89 + 90 + pub async fn validate_email_token( 91 + cache: &dyn Cache, 92 + did: &str, 93 + purpose: EmailTokenPurpose, 94 + token: &str, 95 + ) -> Result<(), TokenError> { 96 + if !cache.is_available() { 97 + return Err(TokenError::CacheUnavailable); 98 + } 99 + 100 + let key = cache_key(did, purpose); 101 + let json = cache.get(&key).await.ok_or(TokenError::InvalidToken)?; 102 + 103 + let data: TokenData = serde_json::from_str(&json).map_err(|_| TokenError::InvalidToken)?; 104 + 105 + let elapsed = current_timestamp().saturating_sub(data.created_at); 106 + if elapsed > TOKEN_TTL_SECS { 107 + let _ = cache.delete(&key).await; 108 + return Err(TokenError::ExpiredToken); 109 + } 110 + 111 + let normalized_input = token.to_uppercase().replace('-', ""); 112 + let normalized_stored = data.token.to_uppercase().replace('-', ""); 113 + 114 + if !constant_time_eq(normalized_input.as_bytes(), normalized_stored.as_bytes()) { 115 + return Err(TokenError::InvalidToken); 116 + } 117 + 118 + let _ = cache.delete(&key).await; 119 + 120 + Ok(()) 121 + } 122 + 123 + pub async fn delete_email_token(cache: &dyn Cache, did: &str, purpose: EmailTokenPurpose) { 124 + let _ = cache.delete(&cache_key(did, purpose)).await; 125 + } 126 + 127 + fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { 128 + if a.len() != b.len() { 129 + return false; 130 + } 131 + a.iter() 132 + .zip(b.iter()) 133 + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) 134 + == 0 135 + } 136 + 137 + #[cfg(test)] 138 + mod tests { 139 + use super::*; 140 + use crate::cache::CacheError; 141 + use async_trait::async_trait; 142 + use std::collections::HashMap; 143 + use std::sync::Mutex; 144 + 145 + struct MockCache { 146 + data: Mutex<HashMap<String, (String, u64)>>, 147 + } 148 + 149 + impl MockCache { 150 + fn new() -> Self { 151 + Self { 152 + data: Mutex::new(HashMap::new()), 153 + } 154 + } 155 + } 156 + 157 + #[async_trait] 158 + impl Cache for MockCache { 159 + async fn get(&self, key: &str) -> Option<String> { 160 + let data = self.data.lock().unwrap(); 161 + let now = current_timestamp(); 162 + data.get(key) 163 + .filter(|(_, exp)| *exp > now) 164 + .map(|(v, _)| v.clone()) 165 + } 166 + 167 + async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> { 168 + let mut data = self.data.lock().unwrap(); 169 + let expires = current_timestamp() + ttl.as_secs(); 170 + data.insert(key.to_string(), (value.to_string(), expires)); 171 + Ok(()) 172 + } 173 + 174 + async fn delete(&self, key: &str) -> Result<(), CacheError> { 175 + let mut data = self.data.lock().unwrap(); 176 + data.remove(key); 177 + Ok(()) 178 + } 179 + 180 + async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> { 181 + None 182 + } 183 + 184 + async fn set_bytes( 185 + &self, 186 + _key: &str, 187 + _value: &[u8], 188 + _ttl: Duration, 189 + ) -> Result<(), CacheError> { 190 + Ok(()) 191 + } 192 + 193 + fn is_available(&self) -> bool { 194 + true 195 + } 196 + } 197 + 198 + #[tokio::test] 199 + async fn test_create_and_validate_token() { 200 + let cache = MockCache::new(); 201 + let did = "did:plc:test123"; 202 + 203 + let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail) 204 + .await 205 + .unwrap(); 206 + 207 + assert_eq!(token.len(), 11); 208 + assert!(token.contains('-')); 209 + 210 + let result = 211 + validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &token).await; 212 + assert!(result.is_ok()); 213 + } 214 + 215 + #[tokio::test] 216 + async fn test_token_consumed_after_use() { 217 + let cache = MockCache::new(); 218 + let did = "did:plc:test123"; 219 + 220 + let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail) 221 + .await 222 + .unwrap(); 223 + 224 + validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &token) 225 + .await 226 + .unwrap(); 227 + 228 + let result = 229 + validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &token).await; 230 + assert_eq!(result.unwrap_err(), TokenError::InvalidToken); 231 + } 232 + 233 + #[tokio::test] 234 + async fn test_invalid_token_rejected() { 235 + let cache = MockCache::new(); 236 + let did = "did:plc:test123"; 237 + 238 + let _token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail) 239 + .await 240 + .unwrap(); 241 + 242 + let result = 243 + validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, "XXXXX-XXXXX").await; 244 + assert_eq!(result.unwrap_err(), TokenError::InvalidToken); 245 + } 246 + 247 + #[tokio::test] 248 + async fn test_wrong_purpose_rejected() { 249 + let cache = MockCache::new(); 250 + let did = "did:plc:test123"; 251 + 252 + let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail) 253 + .await 254 + .unwrap(); 255 + 256 + let result = 257 + validate_email_token(&cache, did, EmailTokenPurpose::ConfirmEmail, &token).await; 258 + assert_eq!(result.unwrap_err(), TokenError::InvalidToken); 259 + } 260 + 261 + #[tokio::test] 262 + async fn test_token_format() { 263 + (0..100).for_each(|_| { 264 + let token = generate_short_token(); 265 + assert_eq!(token.len(), 11); 266 + assert_eq!(&token[5..6], "-"); 267 + assert!( 268 + token[0..5] 269 + .chars() 270 + .all(|c| BASE32_CHARS.contains(&(c as u8))) 271 + ); 272 + assert!( 273 + token[6..11] 274 + .chars() 275 + .all(|c| BASE32_CHARS.contains(&(c as u8))) 276 + ); 277 + }); 278 + } 279 + 280 + #[tokio::test] 281 + async fn test_case_insensitive_validation() { 282 + let cache = MockCache::new(); 283 + let did = "did:plc:test123"; 284 + 285 + let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail) 286 + .await 287 + .unwrap(); 288 + 289 + let lowercase = token.to_lowercase(); 290 + let result = 291 + validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &lowercase).await; 292 + assert!(result.is_ok()); 293 + } 294 + 295 + #[tokio::test] 296 + async fn test_noop_cache_returns_unavailable() { 297 + let cache = crate::cache::NoOpCache; 298 + let did = "did:plc:test"; 299 + 300 + let result = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail).await; 301 + assert_eq!(result.unwrap_err(), TokenError::CacheUnavailable); 302 + } 303 + }
+514
crates/tranquil-pds/src/auth/legacy_2fa.rs
··· 1 + use chrono::Utc; 2 + use rand::Rng; 3 + use serde::{Deserialize, Serialize}; 4 + use std::time::Duration; 5 + 6 + use crate::cache::Cache; 7 + use crate::types::Did; 8 + 9 + const CHALLENGE_TTL_SECS: u64 = 300; 10 + const MIN_REMAINING_TTL_SECS: u64 = 10; 11 + const MAX_ATTEMPTS: u8 = 5; 12 + const CODE_LENGTH: usize = 8; 13 + const COOLDOWN_SECS: u64 = 60; 14 + 15 + #[derive(Debug, Clone, Serialize, Deserialize)] 16 + struct ChallengeData { 17 + code: String, 18 + attempts: u8, 19 + created_at: u64, 20 + } 21 + 22 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 23 + pub enum ChallengeError { 24 + CacheUnavailable, 25 + RateLimited, 26 + CacheError, 27 + } 28 + 29 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 30 + pub enum ValidationError { 31 + InvalidCode, 32 + TooManyAttempts, 33 + ChallengeNotFound, 34 + ChallengeExpired, 35 + CacheUnavailable, 36 + CacheError, 37 + } 38 + 39 + #[derive(Debug)] 40 + pub struct ChallengeCode(String); 41 + 42 + impl ChallengeCode { 43 + pub fn as_str(&self) -> &str { 44 + &self.0 45 + } 46 + } 47 + 48 + impl std::fmt::Display for ChallengeCode { 49 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 50 + write!(f, "{}", self.0) 51 + } 52 + } 53 + 54 + pub async fn create_challenge( 55 + cache: &dyn Cache, 56 + did: &Did, 57 + ) -> Result<ChallengeCode, ChallengeError> { 58 + create_challenge_code(cache, did).await 59 + } 60 + 61 + pub async fn clear_challenge(cache: &dyn Cache, did: &Did) { 62 + let _ = cache.delete(&challenge_key(did.as_str())).await; 63 + let _ = cache.delete(&cooldown_key(did.as_str())).await; 64 + } 65 + 66 + async fn validate_challenge_internal( 67 + cache: &dyn Cache, 68 + did: &str, 69 + code: &str, 70 + ) -> Result<(), ValidationError> { 71 + if !cache.is_available() { 72 + return Err(ValidationError::CacheUnavailable); 73 + } 74 + 75 + let challenge_k = challenge_key(did); 76 + 77 + let json = cache 78 + .get(&challenge_k) 79 + .await 80 + .ok_or(ValidationError::ChallengeNotFound)?; 81 + 82 + let data: ChallengeData = 83 + serde_json::from_str(&json).map_err(|_| ValidationError::ChallengeNotFound)?; 84 + 85 + if data.attempts >= MAX_ATTEMPTS { 86 + let _ = cache.delete(&challenge_k).await; 87 + return Err(ValidationError::TooManyAttempts); 88 + } 89 + 90 + let elapsed = current_timestamp().saturating_sub(data.created_at); 91 + let remaining_ttl = CHALLENGE_TTL_SECS.saturating_sub(elapsed); 92 + if remaining_ttl < MIN_REMAINING_TTL_SECS { 93 + let _ = cache.delete(&challenge_k).await; 94 + return Err(ValidationError::ChallengeExpired); 95 + } 96 + 97 + if !constant_time_eq(code.as_bytes(), data.code.as_bytes()) { 98 + let updated = ChallengeData { 99 + code: data.code, 100 + attempts: data.attempts + 1, 101 + created_at: data.created_at, 102 + }; 103 + let updated_json = 104 + serde_json::to_string(&updated).map_err(|_| ValidationError::CacheError)?; 105 + cache 106 + .set( 107 + &challenge_k, 108 + &updated_json, 109 + Duration::from_secs(remaining_ttl), 110 + ) 111 + .await 112 + .map_err(|_| ValidationError::CacheError)?; 113 + return Err(ValidationError::InvalidCode); 114 + } 115 + 116 + let _ = cache.delete(&challenge_k).await; 117 + let _ = cache.delete(&cooldown_key(did)).await; 118 + 119 + Ok(()) 120 + } 121 + 122 + fn challenge_key(did: &str) -> String { 123 + format!("legacy_2fa:{}", did) 124 + } 125 + 126 + fn cooldown_key(did: &str) -> String { 127 + format!("legacy_2fa_cooldown:{}", did) 128 + } 129 + 130 + fn generate_code() -> String { 131 + let mut rng = rand::thread_rng(); 132 + (0..CODE_LENGTH) 133 + .map(|_| rng.gen_range(0..10).to_string()) 134 + .collect() 135 + } 136 + 137 + fn current_timestamp() -> u64 { 138 + Utc::now().timestamp().max(0) as u64 139 + } 140 + 141 + fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { 142 + if a.len() != b.len() { 143 + return false; 144 + } 145 + a.iter() 146 + .zip(b.iter()) 147 + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) 148 + == 0 149 + } 150 + 151 + pub enum Legacy2faOutcome { 152 + NotRequired, 153 + Blocked, 154 + ChallengeSent(ChallengeCode), 155 + Verified, 156 + } 157 + 158 + pub struct Legacy2faContext { 159 + pub email_2fa_enabled: bool, 160 + pub has_totp: bool, 161 + pub allow_legacy_login: bool, 162 + } 163 + 164 + impl Legacy2faContext { 165 + pub fn requires_2fa(&self) -> bool { 166 + self.email_2fa_enabled || self.has_totp 167 + } 168 + 169 + pub fn is_blocked(&self) -> bool { 170 + self.has_totp && !self.allow_legacy_login && !self.email_2fa_enabled 171 + } 172 + } 173 + 174 + pub async fn process_legacy_2fa( 175 + cache: &dyn Cache, 176 + did: &Did, 177 + ctx: &Legacy2faContext, 178 + auth_factor_token: Option<&str>, 179 + ) -> Result<Legacy2faOutcome, Legacy2faFlowError> { 180 + if !ctx.requires_2fa() { 181 + return Ok(Legacy2faOutcome::NotRequired); 182 + } 183 + 184 + if ctx.is_blocked() { 185 + return Ok(Legacy2faOutcome::Blocked); 186 + } 187 + 188 + match auth_factor_token.filter(|t| !t.is_empty()) { 189 + None => { 190 + let code = create_challenge_code(cache, did).await?; 191 + Ok(Legacy2faOutcome::ChallengeSent(code)) 192 + } 193 + Some(token) => { 194 + validate_challenge(cache, did, token).await?; 195 + Ok(Legacy2faOutcome::Verified) 196 + } 197 + } 198 + } 199 + 200 + pub async fn validate_challenge( 201 + cache: &dyn Cache, 202 + did: &Did, 203 + code: &str, 204 + ) -> Result<(), ValidationError> { 205 + validate_challenge_internal(cache, did.as_str(), code).await 206 + } 207 + 208 + async fn create_challenge_code( 209 + cache: &dyn Cache, 210 + did: &Did, 211 + ) -> Result<ChallengeCode, ChallengeError> { 212 + if !cache.is_available() { 213 + return Err(ChallengeError::CacheUnavailable); 214 + } 215 + 216 + let cooldown = cooldown_key(did.as_str()); 217 + if cache.get(&cooldown).await.is_some() { 218 + return Err(ChallengeError::RateLimited); 219 + } 220 + 221 + let code = generate_code(); 222 + let now = current_timestamp(); 223 + 224 + let data = ChallengeData { 225 + code: code.clone(), 226 + attempts: 0, 227 + created_at: now, 228 + }; 229 + 230 + let json = serde_json::to_string(&data).map_err(|_| ChallengeError::CacheError)?; 231 + 232 + cache 233 + .set( 234 + &challenge_key(did.as_str()), 235 + &json, 236 + Duration::from_secs(CHALLENGE_TTL_SECS), 237 + ) 238 + .await 239 + .map_err(|_| ChallengeError::CacheError)?; 240 + 241 + cache 242 + .set(&cooldown, "1", Duration::from_secs(COOLDOWN_SECS)) 243 + .await 244 + .map_err(|_| ChallengeError::CacheError)?; 245 + 246 + Ok(ChallengeCode(code)) 247 + } 248 + 249 + #[derive(Debug)] 250 + pub enum Legacy2faFlowError { 251 + Challenge(ChallengeError), 252 + Validation(ValidationError), 253 + } 254 + 255 + impl From<ChallengeError> for Legacy2faFlowError { 256 + fn from(e: ChallengeError) -> Self { 257 + Self::Challenge(e) 258 + } 259 + } 260 + 261 + impl From<ValidationError> for Legacy2faFlowError { 262 + fn from(e: ValidationError) -> Self { 263 + Self::Validation(e) 264 + } 265 + } 266 + 267 + #[cfg(test)] 268 + mod tests { 269 + use super::*; 270 + use crate::cache::CacheError; 271 + use async_trait::async_trait; 272 + use std::collections::HashMap; 273 + use std::sync::Mutex; 274 + 275 + struct MockCache { 276 + data: Mutex<HashMap<String, (String, u64)>>, 277 + } 278 + 279 + impl MockCache { 280 + fn new() -> Self { 281 + Self { 282 + data: Mutex::new(HashMap::new()), 283 + } 284 + } 285 + } 286 + 287 + #[async_trait] 288 + impl Cache for MockCache { 289 + async fn get(&self, key: &str) -> Option<String> { 290 + let data = self.data.lock().unwrap(); 291 + let now = current_timestamp(); 292 + data.get(key) 293 + .filter(|(_, exp)| *exp > now) 294 + .map(|(v, _)| v.clone()) 295 + } 296 + 297 + async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> { 298 + let mut data = self.data.lock().unwrap(); 299 + let expires = current_timestamp() + ttl.as_secs(); 300 + data.insert(key.to_string(), (value.to_string(), expires)); 301 + Ok(()) 302 + } 303 + 304 + async fn delete(&self, key: &str) -> Result<(), CacheError> { 305 + let mut data = self.data.lock().unwrap(); 306 + data.remove(key); 307 + Ok(()) 308 + } 309 + 310 + async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> { 311 + None 312 + } 313 + 314 + async fn set_bytes( 315 + &self, 316 + _key: &str, 317 + _value: &[u8], 318 + _ttl: Duration, 319 + ) -> Result<(), CacheError> { 320 + Ok(()) 321 + } 322 + 323 + fn is_available(&self) -> bool { 324 + true 325 + } 326 + } 327 + 328 + #[tokio::test] 329 + async fn test_create_and_validate_challenge() { 330 + let cache = MockCache::new(); 331 + let did = Did::new("did:plc:test123".to_string()).unwrap(); 332 + 333 + let code = create_challenge(&cache, &did).await.unwrap(); 334 + assert_eq!(code.as_str().len(), CODE_LENGTH); 335 + 336 + let result = validate_challenge(&cache, &did, code.as_str()).await; 337 + assert!(result.is_ok()); 338 + } 339 + 340 + #[tokio::test] 341 + async fn test_invalid_code_rejected() { 342 + let cache = MockCache::new(); 343 + let did = Did::new("did:plc:test123".to_string()).unwrap(); 344 + 345 + let _code = create_challenge(&cache, &did).await.unwrap(); 346 + let result = validate_challenge(&cache, &did, "00000000").await; 347 + assert_eq!(result.unwrap_err(), ValidationError::InvalidCode); 348 + } 349 + 350 + #[tokio::test] 351 + async fn test_challenge_consumed_on_success() { 352 + let cache = MockCache::new(); 353 + let did = Did::new("did:plc:test123".to_string()).unwrap(); 354 + 355 + let code = create_challenge(&cache, &did).await.unwrap(); 356 + validate_challenge(&cache, &did, code.as_str()) 357 + .await 358 + .unwrap(); 359 + 360 + let result = validate_challenge(&cache, &did, code.as_str()).await; 361 + assert_eq!(result.unwrap_err(), ValidationError::ChallengeNotFound); 362 + } 363 + 364 + #[tokio::test] 365 + async fn test_max_attempts_exceeded() { 366 + let cache = MockCache::new(); 367 + let did = Did::new("did:plc:test123".to_string()).unwrap(); 368 + 369 + let _code = create_challenge(&cache, &did).await.unwrap(); 370 + 371 + (0..MAX_ATTEMPTS).for_each(|_| { 372 + let _ = futures::executor::block_on(validate_challenge(&cache, &did, "wrong123")); 373 + }); 374 + 375 + let result = validate_challenge(&cache, &did, "anything").await; 376 + assert_eq!(result.unwrap_err(), ValidationError::TooManyAttempts); 377 + } 378 + 379 + #[tokio::test] 380 + async fn test_rate_limiting() { 381 + let cache = MockCache::new(); 382 + let did = Did::new("did:plc:test123".to_string()).unwrap(); 383 + 384 + let _first = create_challenge(&cache, &did).await.unwrap(); 385 + let result = create_challenge(&cache, &did).await; 386 + assert_eq!(result.unwrap_err(), ChallengeError::RateLimited); 387 + } 388 + 389 + #[tokio::test] 390 + async fn test_noop_cache_returns_unavailable() { 391 + let cache = crate::cache::NoOpCache; 392 + let did = Did::new("did:plc:test".to_string()).unwrap(); 393 + 394 + let result = create_challenge(&cache, &did).await; 395 + assert_eq!(result.unwrap_err(), ChallengeError::CacheUnavailable); 396 + } 397 + 398 + #[tokio::test] 399 + async fn test_code_generation_is_numeric() { 400 + (0..100).for_each(|_| { 401 + let code = generate_code(); 402 + assert!(code.chars().all(|c| c.is_ascii_digit())); 403 + assert_eq!(code.len(), CODE_LENGTH); 404 + }); 405 + } 406 + 407 + #[tokio::test] 408 + async fn test_constant_time_eq() { 409 + assert!(constant_time_eq(b"12345678", b"12345678")); 410 + assert!(!constant_time_eq(b"12345678", b"12345679")); 411 + assert!(!constant_time_eq(b"12345678", b"1234567")); 412 + assert!(!constant_time_eq(b"", b"1")); 413 + assert!(constant_time_eq(b"", b"")); 414 + } 415 + 416 + #[tokio::test] 417 + async fn test_process_flow_not_required() { 418 + let cache = MockCache::new(); 419 + let did = Did::new("did:plc:test".to_string()).unwrap(); 420 + let ctx = Legacy2faContext { 421 + email_2fa_enabled: false, 422 + has_totp: false, 423 + allow_legacy_login: true, 424 + }; 425 + 426 + let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap(); 427 + assert!(matches!(outcome, Legacy2faOutcome::NotRequired)); 428 + } 429 + 430 + #[tokio::test] 431 + async fn test_process_flow_blocked() { 432 + let cache = MockCache::new(); 433 + let did = Did::new("did:plc:test".to_string()).unwrap(); 434 + let ctx = Legacy2faContext { 435 + email_2fa_enabled: false, 436 + has_totp: true, 437 + allow_legacy_login: false, 438 + }; 439 + 440 + let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap(); 441 + assert!(matches!(outcome, Legacy2faOutcome::Blocked)); 442 + } 443 + 444 + #[tokio::test] 445 + async fn test_process_flow_challenge_sent_totp() { 446 + let cache = MockCache::new(); 447 + let did = Did::new("did:plc:test".to_string()).unwrap(); 448 + let ctx = Legacy2faContext { 449 + email_2fa_enabled: false, 450 + has_totp: true, 451 + allow_legacy_login: true, 452 + }; 453 + 454 + let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap(); 455 + assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_))); 456 + } 457 + 458 + #[tokio::test] 459 + async fn test_process_flow_challenge_sent_email_2fa_enabled() { 460 + let cache = MockCache::new(); 461 + let did = Did::new("did:plc:test2".to_string()).unwrap(); 462 + let ctx = Legacy2faContext { 463 + email_2fa_enabled: true, 464 + has_totp: false, 465 + allow_legacy_login: false, 466 + }; 467 + 468 + let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap(); 469 + assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_))); 470 + } 471 + 472 + #[tokio::test] 473 + async fn test_process_flow_verified() { 474 + let cache = MockCache::new(); 475 + let did = Did::new("did:plc:test".to_string()).unwrap(); 476 + let ctx = Legacy2faContext { 477 + email_2fa_enabled: true, 478 + has_totp: false, 479 + allow_legacy_login: false, 480 + }; 481 + 482 + let code = create_challenge(&cache, &did).await.unwrap(); 483 + 484 + let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str())) 485 + .await 486 + .unwrap(); 487 + assert!(matches!(outcome, Legacy2faOutcome::Verified)); 488 + } 489 + 490 + #[tokio::test] 491 + async fn test_attempts_persist_across_failures() { 492 + let cache = MockCache::new(); 493 + let did = Did::new("did:plc:test123".to_string()).unwrap(); 494 + 495 + let code = create_challenge(&cache, &did).await.unwrap(); 496 + 497 + (0..3).for_each(|_| { 498 + let result = futures::executor::block_on(validate_challenge(&cache, &did, "wrong123")); 499 + assert_eq!(result.unwrap_err(), ValidationError::InvalidCode); 500 + }); 501 + 502 + let result = validate_challenge(&cache, &did, code.as_str()).await; 503 + assert!(result.is_ok()); 504 + } 505 + 506 + #[tokio::test] 507 + async fn test_validation_on_noop_cache_returns_unavailable() { 508 + let cache = crate::cache::NoOpCache; 509 + let did = Did::new("did:plc:test".to_string()).unwrap(); 510 + 511 + let result = validate_challenge(&cache, &did, "12345678").await; 512 + assert_eq!(result.unwrap_err(), ValidationError::CacheUnavailable); 513 + } 514 + }
+2
crates/tranquil-pds/src/auth/mod.rs
··· 11 11 use tranquil_db_traits::OAuthRepository; 12 12 13 13 pub mod account_verified; 14 + pub mod email_token; 14 15 pub mod extractor; 16 + pub mod legacy_2fa; 15 17 pub mod login_identifier; 16 18 pub mod mfa_verified; 17 19 pub mod scope_check;
+51
crates/tranquil-pds/src/comms/service.rs
··· 403 403 .await 404 404 } 405 405 406 + pub async fn enqueue_short_token_email( 407 + user_repo: &dyn UserRepository, 408 + infra_repo: &dyn InfraRepository, 409 + user_id: Uuid, 410 + token: &str, 411 + purpose: &str, 412 + hostname: &str, 413 + ) -> Result<Uuid, DbError> { 414 + let prefs = user_repo 415 + .get_comms_prefs(user_id) 416 + .await? 417 + .ok_or(DbError::NotFound)?; 418 + let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); 419 + let current_email = prefs.email.clone().unwrap_or_default(); 420 + 421 + let (subject_template, body_template, comms_type) = match purpose { 422 + "email_update" => ( 423 + strings.email_update_subject, 424 + strings.short_token_body, 425 + CommsType::EmailUpdate, 426 + ), 427 + _ => ( 428 + strings.email_update_subject, 429 + strings.short_token_body, 430 + CommsType::EmailUpdate, 431 + ), 432 + }; 433 + 434 + let verify_page = format!("https://{}/app/settings", hostname); 435 + let body = format_message( 436 + body_template, 437 + &[ 438 + ("handle", &prefs.handle), 439 + ("code", token), 440 + ("verify_page", &verify_page), 441 + ], 442 + ); 443 + let subject = format_message(subject_template, &[("hostname", hostname)]); 444 + infra_repo 445 + .enqueue_comms( 446 + Some(user_id), 447 + tranquil_db_traits::CommsChannel::Email, 448 + comms_type, 449 + &current_email, 450 + Some(&subject), 451 + &body, 452 + None, 453 + ) 454 + .await 455 + } 456 + 406 457 pub async fn enqueue_account_deletion( 407 458 user_repo: &dyn UserRepository, 408 459 infra_repo: &dyn InfraRepository,
+6 -4
crates/tranquil-pds/tests/actor.rs
··· 115 115 let body: Value = resp.json().await.unwrap(); 116 116 let prefs_arr = body["preferences"].as_array().unwrap(); 117 117 assert_eq!(prefs_arr.len(), 3); 118 - let adult_pref = prefs_arr 119 - .iter() 120 - .find(|p| p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#adultContentPref")); 118 + let adult_pref = prefs_arr.iter().find(|p| { 119 + p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#adultContentPref") 120 + }); 121 121 assert!(adult_pref.is_some()); 122 122 assert_eq!(adult_pref.unwrap()["enabled"], false); 123 123 let content_label_prefs: Vec<&Value> = prefs_arr 124 124 .iter() 125 - .filter(|p| p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#contentLabelPref")) 125 + .filter(|p| { 126 + p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#contentLabelPref") 127 + }) 126 128 .collect(); 127 129 assert_eq!(content_label_prefs.len(), 2); 128 130 let dogs_pref = content_label_prefs
+481
crates/tranquil-pds/tests/legacy_2fa.rs
··· 1 + mod common; 2 + 3 + use common::{base_url, client, create_account_and_login, get_test_db_pool}; 4 + use reqwest::StatusCode; 5 + use serde_json::{Value, json}; 6 + 7 + async fn enable_totp_for_user(did: &str) { 8 + let pool = get_test_db_pool().await; 9 + let secret = vec![0u8; 20]; 10 + sqlx::query( 11 + r#"INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at) 12 + VALUES ($1, $2, 1, TRUE, NOW()) 13 + ON CONFLICT (did) DO UPDATE SET verified = TRUE"#, 14 + ) 15 + .bind(did) 16 + .bind(&secret) 17 + .execute(pool) 18 + .await 19 + .expect("Failed to enable TOTP"); 20 + } 21 + 22 + async fn set_allow_legacy_login(did: &str, allow: bool) { 23 + let pool = get_test_db_pool().await; 24 + sqlx::query("UPDATE users SET allow_legacy_login = $1 WHERE did = $2") 25 + .bind(allow) 26 + .bind(did) 27 + .execute(pool) 28 + .await 29 + .expect("Failed to set allow_legacy_login"); 30 + } 31 + 32 + async fn get_2fa_code_from_queue(did: &str) -> Option<String> { 33 + let pool = get_test_db_pool().await; 34 + let row: Option<(String,)> = sqlx::query_as( 35 + r#"SELECT body FROM comms_queue 36 + WHERE user_id = (SELECT id FROM users WHERE did = $1) 37 + AND comms_type = 'two_factor_code' 38 + ORDER BY created_at DESC LIMIT 1"#, 39 + ) 40 + .bind(did) 41 + .fetch_optional(pool) 42 + .await 43 + .ok() 44 + .flatten(); 45 + 46 + row.and_then(|(body,)| { 47 + body.lines() 48 + .find(|line: &&str| line.chars().all(|c: char| c.is_ascii_digit()) && line.len() == 8) 49 + .map(|s: &str| s.to_string()) 50 + .or_else(|| { 51 + body.split_whitespace() 52 + .find(|word: &&str| { 53 + word.chars().all(|c: char| c.is_ascii_digit()) && word.len() == 8 54 + }) 55 + .map(|s: &str| s.to_string()) 56 + }) 57 + }) 58 + } 59 + 60 + async fn clear_2fa_challenges_for_user(did: &str) { 61 + let pool = get_test_db_pool().await; 62 + let _ = sqlx::query( 63 + "DELETE FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'two_factor_code'", 64 + ) 65 + .bind(did) 66 + .execute(pool) 67 + .await; 68 + } 69 + 70 + async fn set_email_auth_factor(did: &str, enabled: bool) { 71 + let pool = get_test_db_pool().await; 72 + let user_id: uuid::Uuid = 73 + sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE did = $1") 74 + .bind(did) 75 + .fetch_one(pool) 76 + .await 77 + .expect("Failed to get user id"); 78 + let pool = get_test_db_pool().await; 79 + let _ = sqlx::query( 80 + "DELETE FROM account_preferences WHERE user_id = $1 AND name = 'email_auth_factor'", 81 + ) 82 + .bind(user_id) 83 + .execute(pool) 84 + .await; 85 + let pool = get_test_db_pool().await; 86 + sqlx::query( 87 + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2::jsonb)", 88 + ) 89 + .bind(user_id) 90 + .bind(serde_json::json!(enabled)) 91 + .execute(pool) 92 + .await 93 + .expect("Failed to set email_auth_factor"); 94 + } 95 + 96 + #[tokio::test] 97 + async fn test_legacy_2fa_auth_factor_required() { 98 + let client = client(); 99 + let base = base_url().await; 100 + let (_token, did) = create_account_and_login(&client).await; 101 + 102 + enable_totp_for_user(&did).await; 103 + set_allow_legacy_login(&did, true).await; 104 + 105 + let pool = get_test_db_pool().await; 106 + let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") 107 + .bind(&did) 108 + .fetch_one(pool) 109 + .await 110 + .expect("Failed to get handle"); 111 + 112 + let login_payload = json!({ 113 + "identifier": handle, 114 + "password": "Testpass123!" 115 + }); 116 + let resp = client 117 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 118 + .json(&login_payload) 119 + .send() 120 + .await 121 + .unwrap(); 122 + 123 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 124 + let body: Value = resp.json().await.unwrap(); 125 + assert_eq!(body["error"], "AuthFactorTokenRequired"); 126 + assert!( 127 + body["message"] 128 + .as_str() 129 + .unwrap_or("") 130 + .contains("sign in code") 131 + ); 132 + } 133 + 134 + #[tokio::test] 135 + async fn test_legacy_2fa_valid_code_succeeds() { 136 + let client = client(); 137 + let base = base_url().await; 138 + let (_token, did) = create_account_and_login(&client).await; 139 + 140 + enable_totp_for_user(&did).await; 141 + set_allow_legacy_login(&did, true).await; 142 + clear_2fa_challenges_for_user(&did).await; 143 + 144 + let pool = get_test_db_pool().await; 145 + let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") 146 + .bind(&did) 147 + .fetch_one(pool) 148 + .await 149 + .expect("Failed to get handle"); 150 + 151 + let login_payload = json!({ 152 + "identifier": handle, 153 + "password": "Testpass123!" 154 + }); 155 + let resp = client 156 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 157 + .json(&login_payload) 158 + .send() 159 + .await 160 + .unwrap(); 161 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 162 + 163 + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; 164 + let code = get_2fa_code_from_queue(&did) 165 + .await 166 + .expect("2FA code should be in queue"); 167 + 168 + let login_with_code = json!({ 169 + "identifier": handle, 170 + "password": "Testpass123!", 171 + "authFactorToken": code 172 + }); 173 + let resp = client 174 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 175 + .json(&login_with_code) 176 + .send() 177 + .await 178 + .unwrap(); 179 + 180 + assert_eq!(resp.status(), StatusCode::OK); 181 + let body: Value = resp.json().await.unwrap(); 182 + assert!(body.get("accessJwt").is_some()); 183 + assert!(body.get("refreshJwt").is_some()); 184 + assert_eq!(body["did"], did); 185 + } 186 + 187 + #[tokio::test] 188 + async fn test_legacy_2fa_invalid_code_rejected() { 189 + let client = client(); 190 + let base = base_url().await; 191 + let (_token, did) = create_account_and_login(&client).await; 192 + 193 + enable_totp_for_user(&did).await; 194 + set_allow_legacy_login(&did, true).await; 195 + clear_2fa_challenges_for_user(&did).await; 196 + 197 + let pool = get_test_db_pool().await; 198 + let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") 199 + .bind(&did) 200 + .fetch_one(pool) 201 + .await 202 + .expect("Failed to get handle"); 203 + 204 + let resp = client 205 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 206 + .json(&json!({ 207 + "identifier": handle, 208 + "password": "Testpass123!" 209 + })) 210 + .send() 211 + .await 212 + .unwrap(); 213 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 214 + 215 + let login_with_bad_code = json!({ 216 + "identifier": handle, 217 + "password": "Testpass123!", 218 + "authFactorToken": "00000000" 219 + }); 220 + let resp = client 221 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 222 + .json(&login_with_bad_code) 223 + .send() 224 + .await 225 + .unwrap(); 226 + 227 + let status = resp.status(); 228 + let body: Value = resp.json().await.unwrap(); 229 + assert_eq!( 230 + status, 231 + StatusCode::BAD_REQUEST, 232 + "Expected 400, got {}. Response: {:?}", 233 + status, 234 + body 235 + ); 236 + assert_eq!(body["error"], "InvalidCode"); 237 + } 238 + 239 + #[tokio::test] 240 + async fn test_legacy_2fa_blocked_when_disabled() { 241 + let client = client(); 242 + let base = base_url().await; 243 + let (_token, did) = create_account_and_login(&client).await; 244 + 245 + enable_totp_for_user(&did).await; 246 + set_allow_legacy_login(&did, false).await; 247 + 248 + let pool = get_test_db_pool().await; 249 + let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") 250 + .bind(&did) 251 + .fetch_one(pool) 252 + .await 253 + .expect("Failed to get handle"); 254 + 255 + let login_payload = json!({ 256 + "identifier": handle, 257 + "password": "Testpass123!" 258 + }); 259 + let resp = client 260 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 261 + .json(&login_payload) 262 + .send() 263 + .await 264 + .unwrap(); 265 + 266 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 267 + let body: Value = resp.json().await.unwrap(); 268 + assert_eq!(body["error"], "MfaRequired"); 269 + } 270 + 271 + #[tokio::test] 272 + async fn test_legacy_2fa_no_totp_no_challenge() { 273 + let client = client(); 274 + let base = base_url().await; 275 + let (_token, did) = create_account_and_login(&client).await; 276 + 277 + let pool = get_test_db_pool().await; 278 + let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") 279 + .bind(&did) 280 + .fetch_one(pool) 281 + .await 282 + .expect("Failed to get handle"); 283 + 284 + let login_payload = json!({ 285 + "identifier": handle, 286 + "password": "Testpass123!" 287 + }); 288 + let resp = client 289 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 290 + .json(&login_payload) 291 + .send() 292 + .await 293 + .unwrap(); 294 + 295 + assert_eq!(resp.status(), StatusCode::OK); 296 + let body: Value = resp.json().await.unwrap(); 297 + assert!(body.get("accessJwt").is_some()); 298 + } 299 + 300 + #[tokio::test] 301 + async fn test_legacy_2fa_code_consumed_after_use() { 302 + let client = client(); 303 + let base = base_url().await; 304 + let (_token, did) = create_account_and_login(&client).await; 305 + 306 + enable_totp_for_user(&did).await; 307 + set_allow_legacy_login(&did, true).await; 308 + clear_2fa_challenges_for_user(&did).await; 309 + 310 + let pool = get_test_db_pool().await; 311 + let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") 312 + .bind(&did) 313 + .fetch_one(pool) 314 + .await 315 + .expect("Failed to get handle"); 316 + 317 + let resp = client 318 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 319 + .json(&json!({ 320 + "identifier": handle, 321 + "password": "Testpass123!" 322 + })) 323 + .send() 324 + .await 325 + .unwrap(); 326 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 327 + 328 + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; 329 + let code = get_2fa_code_from_queue(&did) 330 + .await 331 + .expect("2FA code should be in queue"); 332 + 333 + let resp = client 334 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 335 + .json(&json!({ 336 + "identifier": handle, 337 + "password": "Testpass123!", 338 + "authFactorToken": code 339 + })) 340 + .send() 341 + .await 342 + .unwrap(); 343 + assert_eq!(resp.status(), StatusCode::OK); 344 + 345 + clear_2fa_challenges_for_user(&did).await; 346 + let resp = client 347 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 348 + .json(&json!({ 349 + "identifier": handle, 350 + "password": "Testpass123!" 351 + })) 352 + .send() 353 + .await 354 + .unwrap(); 355 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 356 + let body: Value = resp.json().await.unwrap(); 357 + assert_eq!(body["error"], "AuthFactorTokenRequired"); 358 + 359 + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; 360 + let new_code = get_2fa_code_from_queue(&did) 361 + .await 362 + .expect("New 2FA code should be in queue"); 363 + 364 + let resp = client 365 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 366 + .json(&json!({ 367 + "identifier": handle, 368 + "password": "Testpass123!", 369 + "authFactorToken": code 370 + })) 371 + .send() 372 + .await 373 + .unwrap(); 374 + let status = resp.status(); 375 + let body: Value = resp.json().await.unwrap(); 376 + assert_eq!( 377 + status, 378 + StatusCode::BAD_REQUEST, 379 + "Expected 400 for old code, got {}. Response: {:?}", 380 + status, 381 + body 382 + ); 383 + assert_eq!(body["error"], "InvalidCode"); 384 + 385 + let resp = client 386 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 387 + .json(&json!({ 388 + "identifier": handle, 389 + "password": "Testpass123!", 390 + "authFactorToken": new_code 391 + })) 392 + .send() 393 + .await 394 + .unwrap(); 395 + assert_eq!(resp.status(), StatusCode::OK); 396 + } 397 + 398 + #[tokio::test] 399 + async fn test_email_auth_factor_requires_code() { 400 + let client = client(); 401 + let base = base_url().await; 402 + let (_token, did) = create_account_and_login(&client).await; 403 + 404 + set_email_auth_factor(&did, true).await; 405 + clear_2fa_challenges_for_user(&did).await; 406 + 407 + let pool = get_test_db_pool().await; 408 + let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") 409 + .bind(&did) 410 + .fetch_one(pool) 411 + .await 412 + .expect("Failed to get handle"); 413 + 414 + let login_payload = json!({ 415 + "identifier": handle, 416 + "password": "Testpass123!" 417 + }); 418 + let resp = client 419 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 420 + .json(&login_payload) 421 + .send() 422 + .await 423 + .unwrap(); 424 + 425 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 426 + let body: Value = resp.json().await.unwrap(); 427 + assert_eq!(body["error"], "AuthFactorTokenRequired"); 428 + 429 + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; 430 + let code = get_2fa_code_from_queue(&did) 431 + .await 432 + .expect("2FA code should be in queue"); 433 + 434 + let login_with_code = json!({ 435 + "identifier": handle, 436 + "password": "Testpass123!", 437 + "authFactorToken": code 438 + }); 439 + let resp = client 440 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 441 + .json(&login_with_code) 442 + .send() 443 + .await 444 + .unwrap(); 445 + 446 + assert_eq!(resp.status(), StatusCode::OK); 447 + let body: Value = resp.json().await.unwrap(); 448 + assert!(body.get("accessJwt").is_some()); 449 + assert_eq!(body["emailAuthFactor"], true); 450 + } 451 + 452 + #[tokio::test] 453 + async fn test_email_auth_factor_disabled_no_challenge() { 454 + let client = client(); 455 + let base = base_url().await; 456 + let (_token, did) = create_account_and_login(&client).await; 457 + 458 + set_email_auth_factor(&did, false).await; 459 + 460 + let pool = get_test_db_pool().await; 461 + let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1") 462 + .bind(&did) 463 + .fetch_one(pool) 464 + .await 465 + .expect("Failed to get handle"); 466 + 467 + let login_payload = json!({ 468 + "identifier": handle, 469 + "password": "Testpass123!" 470 + }); 471 + let resp = client 472 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 473 + .json(&login_payload) 474 + .send() 475 + .await 476 + .unwrap(); 477 + 478 + assert_eq!(resp.status(), StatusCode::OK); 479 + let body: Value = resp.json().await.unwrap(); 480 + assert!(body.get("accessJwt").is_some()); 481 + }
+1 -5
crates/tranquil-pds/tests/shutdown_unit.rs
··· 60 60 61 61 shutdown.cancel(); 62 62 63 - let result = tokio::time::timeout( 64 - std::time::Duration::from_millis(100), 65 - handle, 66 - ) 67 - .await; 63 + let result = tokio::time::timeout(std::time::Duration::from_millis(100), handle).await; 68 64 69 65 assert!(result.is_ok()); 70 66 assert!(result.unwrap().unwrap());

History

2 rounds 2 comments
sign up or login to add to the discussion
1 commit
expand
feat: legacy 2fa impl
expand 0 comments
pull request successfully merged
lewis.moe submitted #0
1 commit
expand
feat: legacy 2fa impl
expand 2 comments

nitpicky but id prefer email_auth_factor in crates/tranquil-db-traits/src/user.rs be called email_2fa_enabled instead. lines up with totp_enabled and is more descriptive

might wanna double check that youve run cargo fmt cause my gut tells me rustfmt wouldnt allow ) .await { and would instead do ).await { but if it does the former it does that ig ...

ok fixed!