this repo has no description

Security key & totp support

lewis 2cddf065 6c8feb9a

Changed files
+4989 -37
.sqlx
frontend
migrations
src
+22
.sqlx/query-0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'registration' AND expires_at > NOW()\n ORDER BY created_at DESC\n LIMIT 1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "state_json", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b" 22 + }
+76
.sqlx/query-23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports\n FROM passkeys\n WHERE did = $1\n ORDER BY created_at DESC\n ", 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": "credential_id", 19 + "type_info": "Bytea" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "public_key", 24 + "type_info": "Bytea" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "sign_count", 29 + "type_info": "Int4" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "created_at", 34 + "type_info": "Timestamptz" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "last_used", 39 + "type_info": "Timestamptz" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "friendly_name", 44 + "type_info": "Text" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "aaguid", 49 + "type_info": "Bytea" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "transports", 54 + "type_info": "TextArray" 55 + } 56 + ], 57 + "parameters": { 58 + "Left": [ 59 + "Text" 60 + ] 61 + }, 62 + "nullable": [ 63 + false, 64 + false, 65 + false, 66 + false, 67 + false, 68 + false, 69 + true, 70 + true, 71 + true, 72 + true 73 + ] 74 + }, 75 + "hash": "23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12" 76 + }
+16
.sqlx/query-2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)\n VALUES ($1, $2, $3, false, NOW())\n ON CONFLICT (did) DO UPDATE SET\n secret_encrypted = $2,\n encryption_version = $3,\n verified = false,\n created_at = NOW(),\n last_used = NULL\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Bytea", 10 + "Int4" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9" 16 + }
+12
.sqlx/query-2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM webauthn_challenges WHERE expires_at < NOW()", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [] 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614" 12 + }
+34
.sqlx/query-2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "secret_encrypted", 9 + "type_info": "Bytea" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "encryption_version", 14 + "type_info": "Int4" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "verified", 19 + "type_info": "Bool" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d" 34 + }
+18
.sqlx/query-418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'authentication', $4, $5)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Bytea", 11 + "Text", 12 + "Timestamptz" 13 + ] 14 + }, 15 + "nullable": [] 16 + }, 17 + "hash": "418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74" 18 + }
+14
.sqlx/query-41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM user_totp WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147" 14 + }
+22
.sqlx/query-470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76" 22 + }
+19
.sqlx/query-4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name, aaguid)\n VALUES ($1, $2, $3, $4, 0, $5, $6)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Bytea", 11 + "Bytea", 12 + "Text", 13 + "Bytea" 14 + ] 15 + }, 16 + "nullable": [] 17 + }, 18 + "hash": "4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb" 19 + }
+46
.sqlx/query-513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT two_factor_enabled, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", id FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "two_factor_enabled", 9 + "type_info": "Bool" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "preferred_comms_channel: CommsChannel", 14 + "type_info": { 15 + "Custom": { 16 + "name": "comms_channel", 17 + "kind": { 18 + "Enum": [ 19 + "email", 20 + "discord", 21 + "telegram", 22 + "signal" 23 + ] 24 + } 25 + } 26 + } 27 + }, 28 + { 29 + "ordinal": 2, 30 + "name": "id", 31 + "type_info": "Uuid" 32 + } 33 + ], 34 + "parameters": { 35 + "Left": [ 36 + "Text" 37 + ] 38 + }, 39 + "nullable": [ 40 + false, 41 + false, 42 + false 43 + ] 44 + }, 45 + "hash": "513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81" 46 + }
+14
.sqlx/query-6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM backup_codes WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0" 14 + }
+15
.sqlx/query-6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Int4", 9 + "Bytea" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3" 15 + }
+22
.sqlx/query-76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'authentication' AND expires_at > NOW()\n ORDER BY created_at DESC\n LIMIT 1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "state_json", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0" 22 + }
+14
.sqlx/query-80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'authentication'", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c" 14 + }
+22
.sqlx/query-a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT did FROM users WHERE handle = $1 OR email = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338" 22 + }
+76
.sqlx/query-aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports\n FROM passkeys\n WHERE credential_id = $1\n ", 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": "credential_id", 19 + "type_info": "Bytea" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "public_key", 24 + "type_info": "Bytea" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "sign_count", 29 + "type_info": "Int4" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "created_at", 34 + "type_info": "Timestamptz" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "last_used", 39 + "type_info": "Timestamptz" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "friendly_name", 44 + "type_info": "Text" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "aaguid", 49 + "type_info": "Bytea" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "transports", 54 + "type_info": "TextArray" 55 + } 56 + ], 57 + "parameters": { 58 + "Left": [ 59 + "Bytea" 60 + ] 61 + }, 62 + "nullable": [ 63 + false, 64 + false, 65 + false, 66 + false, 67 + false, 68 + false, 69 + true, 70 + true, 71 + true, 72 + true 73 + ] 74 + }, 75 + "hash": "aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3" 76 + }
+14
.sqlx/query-b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'registration'", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a" 14 + }
+22
.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT password_hash FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "password_hash", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745" 22 + }
+28
.sqlx/query-cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "code_hash", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a" 28 + }
+14
.sqlx/query-d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE user_totp SET last_used = NOW() WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b" 14 + }
+58
.sqlx/query-e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT did, deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "deactivated_at", 14 + "type_info": "Timestamptz" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "takedown_ref", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "email_verified", 24 + "type_info": "Bool" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "discord_verified", 29 + "type_info": "Bool" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "telegram_verified", 34 + "type_info": "Bool" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "signal_verified", 39 + "type_info": "Bool" 40 + } 41 + ], 42 + "parameters": { 43 + "Left": [ 44 + "Text" 45 + ] 46 + }, 47 + "nullable": [ 48 + false, 49 + true, 50 + true, 51 + false, 52 + false, 53 + false, 54 + false 55 + ] 56 + }, 57 + "hash": "e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c" 58 + }
+22
.sqlx/query-e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT verified FROM user_totp WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "verified", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c" 22 + }
+15
.sqlx/query-e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE backup_codes SET used_at = $1 WHERE id = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz", 9 + "Uuid" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9" 15 + }
+15
.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d" 15 + }
+18
.sqlx/query-eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'registration', $4, $5)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Bytea", 11 + "Text", 12 + "Timestamptz" 13 + ] 14 + }, 15 + "nullable": [] 16 + }, 17 + "hash": "eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed" 18 + }
+14
.sqlx/query-f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af.json
···
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af" 14 + }
+227
Cargo.lock
··· 117 checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" 118 119 [[package]] 120 name = "assert-json-diff" 121 version = "2.0.2" 122 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 778 checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 779 780 [[package]] 781 name = "bcrypt" 782 version = "0.17.1" 783 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1217 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 1218 1219 [[package]] 1220 name = "cordyceps" 1221 version = "0.3.4" 1222 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1561 ] 1562 1563 [[package]] 1564 name = "deranged" 1565 version = "0.5.5" 1566 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3873 ] 3874 3875 [[package]] 3876 name = "once_cell" 3877 version = "1.21.3" 3878 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4046 "structmeta", 4047 "syn 2.0.111", 4048 ] 4049 4050 [[package]] 4051 name = "pem" ··· 4358 ] 4359 4360 [[package]] 4361 name = "quanta" 4362 version = "0.12.6" 4363 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4746 ] 4747 4748 [[package]] 4749 name = "rustix" 4750 version = "1.1.2" 4751 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5037 ] 5038 5039 [[package]] 5040 name = "serde_core" 5041 version = "1.0.228" 5042 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6040 ] 6041 6042 [[package]] 6043 name = "tower" 6044 version = "0.5.2" 6045 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6226 "thiserror 2.0.17", 6227 "tokio", 6228 "tokio-tungstenite", 6229 "tower-http", 6230 "tracing", 6231 "tracing-subscriber", 6232 "urlencoding", 6233 "uuid", 6234 "wiremock", 6235 ] 6236 ··· 6416 "getrandom 0.3.4", 6417 "js-sys", 6418 "rand 0.9.2", 6419 "wasm-bindgen", 6420 ] 6421 ··· 6575 ] 6576 6577 [[package]] 6578 name = "webpage" 6579 version = "2.0.1" 6580 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7082 version = "0.6.2" 7083 source = "registry+https://github.com/rust-lang/crates.io-index" 7084 checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 7085 7086 [[package]] 7087 name = "xattr"
··· 117 checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" 118 119 [[package]] 120 + name = "asn1-rs" 121 + version = "0.6.2" 122 + source = "registry+https://github.com/rust-lang/crates.io-index" 123 + checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" 124 + dependencies = [ 125 + "asn1-rs-derive", 126 + "asn1-rs-impl", 127 + "displaydoc", 128 + "nom", 129 + "num-traits", 130 + "rusticata-macros", 131 + "thiserror 1.0.69", 132 + "time", 133 + ] 134 + 135 + [[package]] 136 + name = "asn1-rs-derive" 137 + version = "0.5.1" 138 + source = "registry+https://github.com/rust-lang/crates.io-index" 139 + checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" 140 + dependencies = [ 141 + "proc-macro2", 142 + "quote", 143 + "syn 2.0.111", 144 + "synstructure", 145 + ] 146 + 147 + [[package]] 148 + name = "asn1-rs-impl" 149 + version = "0.2.0" 150 + source = "registry+https://github.com/rust-lang/crates.io-index" 151 + checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" 152 + dependencies = [ 153 + "proc-macro2", 154 + "quote", 155 + "syn 2.0.111", 156 + ] 157 + 158 + [[package]] 159 name = "assert-json-diff" 160 version = "2.0.2" 161 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 817 checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 818 819 [[package]] 820 + name = "base64urlsafedata" 821 + version = "0.5.4" 822 + source = "registry+https://github.com/rust-lang/crates.io-index" 823 + checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a" 824 + dependencies = [ 825 + "base64 0.21.7", 826 + "pastey", 827 + "serde", 828 + ] 829 + 830 + [[package]] 831 name = "bcrypt" 832 version = "0.17.1" 833 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1267 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 1268 1269 [[package]] 1270 + name = "constant_time_eq" 1271 + version = "0.3.1" 1272 + source = "registry+https://github.com/rust-lang/crates.io-index" 1273 + checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" 1274 + 1275 + [[package]] 1276 name = "cordyceps" 1277 version = "0.3.4" 1278 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1617 ] 1618 1619 [[package]] 1620 + name = "der-parser" 1621 + version = "9.0.0" 1622 + source = "registry+https://github.com/rust-lang/crates.io-index" 1623 + checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" 1624 + dependencies = [ 1625 + "asn1-rs", 1626 + "displaydoc", 1627 + "nom", 1628 + "num-bigint", 1629 + "num-traits", 1630 + "rusticata-macros", 1631 + ] 1632 + 1633 + [[package]] 1634 name = "deranged" 1635 version = "0.5.5" 1636 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3943 ] 3944 3945 [[package]] 3946 + name = "oid-registry" 3947 + version = "0.7.1" 3948 + source = "registry+https://github.com/rust-lang/crates.io-index" 3949 + checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" 3950 + dependencies = [ 3951 + "asn1-rs", 3952 + ] 3953 + 3954 + [[package]] 3955 name = "once_cell" 3956 version = "1.21.3" 3957 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4125 "structmeta", 4126 "syn 2.0.111", 4127 ] 4128 + 4129 + [[package]] 4130 + name = "pastey" 4131 + version = "0.1.1" 4132 + source = "registry+https://github.com/rust-lang/crates.io-index" 4133 + checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" 4134 4135 [[package]] 4136 name = "pem" ··· 4443 ] 4444 4445 [[package]] 4446 + name = "qrcodegen" 4447 + version = "1.8.0" 4448 + source = "registry+https://github.com/rust-lang/crates.io-index" 4449 + checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" 4450 + 4451 + [[package]] 4452 + name = "qrcodegen-image" 4453 + version = "1.5.0" 4454 + source = "registry+https://github.com/rust-lang/crates.io-index" 4455 + checksum = "221b7eace1aef8c95d65dbe09fb7a1a43d006045394a89afba6997721fcb7708" 4456 + dependencies = [ 4457 + "base64 0.22.1", 4458 + "image", 4459 + "qrcodegen", 4460 + ] 4461 + 4462 + [[package]] 4463 name = "quanta" 4464 version = "0.12.6" 4465 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4848 ] 4849 4850 [[package]] 4851 + name = "rusticata-macros" 4852 + version = "4.1.0" 4853 + source = "registry+https://github.com/rust-lang/crates.io-index" 4854 + checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" 4855 + dependencies = [ 4856 + "nom", 4857 + ] 4858 + 4859 + [[package]] 4860 name = "rustix" 4861 version = "1.1.2" 4862 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5148 ] 5149 5150 [[package]] 5151 + name = "serde_cbor_2" 5152 + version = "0.13.0" 5153 + source = "registry+https://github.com/rust-lang/crates.io-index" 5154 + checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" 5155 + dependencies = [ 5156 + "half", 5157 + "serde", 5158 + ] 5159 + 5160 + [[package]] 5161 name = "serde_core" 5162 version = "1.0.228" 5163 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6161 ] 6162 6163 [[package]] 6164 + name = "totp-rs" 6165 + version = "5.7.0" 6166 + source = "registry+https://github.com/rust-lang/crates.io-index" 6167 + checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72" 6168 + dependencies = [ 6169 + "base32", 6170 + "constant_time_eq", 6171 + "hmac", 6172 + "qrcodegen-image", 6173 + "sha1", 6174 + "sha2", 6175 + "url", 6176 + "urlencoding", 6177 + ] 6178 + 6179 + [[package]] 6180 name = "tower" 6181 version = "0.5.2" 6182 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6363 "thiserror 2.0.17", 6364 "tokio", 6365 "tokio-tungstenite", 6366 + "totp-rs", 6367 "tower-http", 6368 "tracing", 6369 "tracing-subscriber", 6370 "urlencoding", 6371 "uuid", 6372 + "webauthn-rs", 6373 + "webauthn-rs-proto", 6374 "wiremock", 6375 ] 6376 ··· 6556 "getrandom 0.3.4", 6557 "js-sys", 6558 "rand 0.9.2", 6559 + "serde_core", 6560 + "sha1_smol", 6561 "wasm-bindgen", 6562 ] 6563 ··· 6717 ] 6718 6719 [[package]] 6720 + name = "webauthn-attestation-ca" 6721 + version = "0.5.4" 6722 + source = "registry+https://github.com/rust-lang/crates.io-index" 6723 + checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b" 6724 + dependencies = [ 6725 + "base64urlsafedata", 6726 + "openssl", 6727 + "openssl-sys", 6728 + "serde", 6729 + "tracing", 6730 + "uuid", 6731 + ] 6732 + 6733 + [[package]] 6734 + name = "webauthn-rs" 6735 + version = "0.5.4" 6736 + source = "registry+https://github.com/rust-lang/crates.io-index" 6737 + checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8" 6738 + dependencies = [ 6739 + "base64urlsafedata", 6740 + "serde", 6741 + "tracing", 6742 + "url", 6743 + "uuid", 6744 + "webauthn-rs-core", 6745 + ] 6746 + 6747 + [[package]] 6748 + name = "webauthn-rs-core" 6749 + version = "0.5.4" 6750 + source = "registry+https://github.com/rust-lang/crates.io-index" 6751 + checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b" 6752 + dependencies = [ 6753 + "base64 0.21.7", 6754 + "base64urlsafedata", 6755 + "der-parser", 6756 + "hex", 6757 + "nom", 6758 + "openssl", 6759 + "openssl-sys", 6760 + "rand 0.9.2", 6761 + "rand_chacha 0.9.0", 6762 + "serde", 6763 + "serde_cbor_2", 6764 + "serde_json", 6765 + "thiserror 1.0.69", 6766 + "tracing", 6767 + "url", 6768 + "uuid", 6769 + "webauthn-attestation-ca", 6770 + "webauthn-rs-proto", 6771 + "x509-parser", 6772 + ] 6773 + 6774 + [[package]] 6775 + name = "webauthn-rs-proto" 6776 + version = "0.5.4" 6777 + source = "registry+https://github.com/rust-lang/crates.io-index" 6778 + checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c" 6779 + dependencies = [ 6780 + "base64 0.21.7", 6781 + "base64urlsafedata", 6782 + "serde", 6783 + "serde_json", 6784 + "url", 6785 + ] 6786 + 6787 + [[package]] 6788 name = "webpage" 6789 version = "2.0.1" 6790 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7292 version = "0.6.2" 7293 source = "registry+https://github.com/rust-lang/crates.io-index" 7294 checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 7295 + 7296 + [[package]] 7297 + name = "x509-parser" 7298 + version = "0.16.0" 7299 + source = "registry+https://github.com/rust-lang/crates.io-index" 7300 + checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" 7301 + dependencies = [ 7302 + "asn1-rs", 7303 + "data-encoding", 7304 + "der-parser", 7305 + "lazy_static", 7306 + "nom", 7307 + "oid-registry", 7308 + "rusticata-macros", 7309 + "thiserror 1.0.69", 7310 + "time", 7311 + ] 7312 7313 [[package]] 7314 name = "xattr"
+4 -1
Cargo.toml
··· 47 tracing-subscriber = "0.3.22" 48 tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } 49 urlencoding = "2.1" 50 - uuid = { version = "1.19.0", features = ["v4", "fast-rng"] } 51 iroh-car = "0.5.1" 52 image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } 53 redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } ··· 56 metrics = "0.24" 57 metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] } 58 bs58 = "0.5.1" 59 [features] 60 external-infra = [] 61 [dev-dependencies]
··· 47 tracing-subscriber = "0.3.22" 48 tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } 49 urlencoding = "2.1" 50 + uuid = { version = "1.19.0", features = ["v4", "v5", "fast-rng"] } 51 iroh-car = "0.5.1" 52 image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } 53 redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } ··· 56 metrics = "0.24" 57 metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] } 58 bs58 = "0.5.1" 59 + totp-rs = { version = "5", features = ["qr"] } 60 + webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 61 + webauthn-rs-proto = "0.5.4" 62 [features] 63 external-infra = [] 64 [dev-dependencies]
+6
frontend/src/App.svelte
··· 17 import OAuthLogin from './routes/OAuthLogin.svelte' 18 import OAuthAccounts from './routes/OAuthAccounts.svelte' 19 import OAuth2FA from './routes/OAuth2FA.svelte' 20 import OAuthError from './routes/OAuthError.svelte' 21 22 const auth = getAuthState() 23 ··· 59 return OAuthAccounts 60 case '/oauth/2fa': 61 return OAuth2FA 62 case '/oauth/error': 63 return OAuthError 64 default: 65 return auth.session ? Dashboard : Login 66 }
··· 17 import OAuthLogin from './routes/OAuthLogin.svelte' 18 import OAuthAccounts from './routes/OAuthAccounts.svelte' 19 import OAuth2FA from './routes/OAuth2FA.svelte' 20 + import OAuthTotp from './routes/OAuthTotp.svelte' 21 import OAuthError from './routes/OAuthError.svelte' 22 + import Security from './routes/Security.svelte' 23 24 const auth = getAuthState() 25 ··· 61 return OAuthAccounts 62 case '/oauth/2fa': 63 return OAuth2FA 64 + case '/oauth/totp': 65 + return OAuthTotp 66 case '/oauth/error': 67 return OAuthError 68 + case '/security': 69 + return Security 70 default: 71 return auth.session ? Dashboard : Login 72 }
+76
frontend/src/lib/api.ts
··· 493 body: { repo, collection, rkey }, 494 }) 495 }, 496 }
··· 493 body: { repo, collection, rkey }, 494 }) 495 }, 496 + 497 + async getTotpStatus(token: string): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { 498 + return xrpc('com.atproto.server.getTotpStatus', { token }) 499 + }, 500 + 501 + async createTotpSecret(token: string): Promise<{ uri: string; qrBase64: string }> { 502 + return xrpc('com.atproto.server.createTotpSecret', { method: 'POST', token }) 503 + }, 504 + 505 + async enableTotp(token: string, code: string): Promise<{ success: boolean; backupCodes: string[] }> { 506 + return xrpc('com.atproto.server.enableTotp', { 507 + method: 'POST', 508 + token, 509 + body: { code }, 510 + }) 511 + }, 512 + 513 + async disableTotp(token: string, password: string, code: string): Promise<{ success: boolean }> { 514 + return xrpc('com.atproto.server.disableTotp', { 515 + method: 'POST', 516 + token, 517 + body: { password, code }, 518 + }) 519 + }, 520 + 521 + async regenerateBackupCodes(token: string, password: string, code: string): Promise<{ backupCodes: string[] }> { 522 + return xrpc('com.atproto.server.regenerateBackupCodes', { 523 + method: 'POST', 524 + token, 525 + body: { password, code }, 526 + }) 527 + }, 528 + 529 + async startPasskeyRegistration(token: string, friendlyName?: string): Promise<{ options: unknown }> { 530 + return xrpc('com.atproto.server.startPasskeyRegistration', { 531 + method: 'POST', 532 + token, 533 + body: { friendlyName }, 534 + }) 535 + }, 536 + 537 + async finishPasskeyRegistration(token: string, credential: unknown, friendlyName?: string): Promise<{ id: string; credentialId: string }> { 538 + return xrpc('com.atproto.server.finishPasskeyRegistration', { 539 + method: 'POST', 540 + token, 541 + body: { credential, friendlyName }, 542 + }) 543 + }, 544 + 545 + async listPasskeys(token: string): Promise<{ 546 + passkeys: Array<{ 547 + id: string 548 + credentialId: string 549 + friendlyName: string | null 550 + createdAt: string 551 + lastUsed: string | null 552 + }> 553 + }> { 554 + return xrpc('com.atproto.server.listPasskeys', { token }) 555 + }, 556 + 557 + async deletePasskey(token: string, id: string): Promise<void> { 558 + await xrpc('com.atproto.server.deletePasskey', { 559 + method: 'POST', 560 + token, 561 + body: { id }, 562 + }) 563 + }, 564 + 565 + async updatePasskey(token: string, id: string, friendlyName: string): Promise<void> { 566 + await xrpc('com.atproto.server.updatePasskey', { 567 + method: 'POST', 568 + token, 569 + body: { id, friendlyName }, 570 + }) 571 + }, 572 }
+4
frontend/src/routes/Dashboard.svelte
··· 155 <h3>Account Settings</h3> 156 <p>Email, password, handle, and more</p> 157 </a> 158 <a href="#/notifications" class="nav-card"> 159 <h3>Notification Preferences</h3> 160 <p>Discord, Telegram, Signal channels</p>
··· 155 <h3>Account Settings</h3> 156 <p>Email, password, handle, and more</p> 157 </a> 158 + <a href="#/security" class="nav-card"> 159 + <h3>Security</h3> 160 + <p>Two-factor authentication</p> 161 + </a> 162 <a href="#/notifications" class="nav-card"> 163 <h3>Notification Preferences</h3> 164 <p>Discord, Telegram, Signal channels</p>
+5
frontend/src/routes/OAuthAccounts.svelte
··· 73 return 74 } 75 76 if (data.needs_2fa) { 77 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 78 return
··· 73 return 74 } 75 76 + if (data.needs_totp) { 77 + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 78 + return 79 + } 80 + 81 if (data.needs_2fa) { 82 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 83 return
+308 -1
frontend/src/routes/OAuthLogin.svelte
··· 6 let rememberDevice = $state(false) 7 let submitting = $state(false) 8 let error = $state<string | null>(null) 9 10 function getRequestUri(): string | null { 11 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') ··· 24 } 25 }) 26 27 async function handleSubmit(e: Event) { 28 e.preventDefault() 29 const requestUri = getRequestUri() ··· 55 if (!response.ok) { 56 error = data.error_description || data.error || 'Login failed' 57 submitting = false 58 return 59 } 60 ··· 106 107 <div class="oauth-login-container"> 108 <h1>Sign In</h1> 109 - <p class="subtitle">Sign in to continue to the application</p> 110 111 {#if error} 112 <div class="error">{error}</div> ··· 126 /> 127 </div> 128 129 <div class="field"> 130 <label for="password">Password</label> 131 <input ··· 265 266 .submit-btn:hover:not(:disabled) { 267 background: var(--accent-hover); 268 } 269 </style>
··· 6 let rememberDevice = $state(false) 7 let submitting = $state(false) 8 let error = $state<string | null>(null) 9 + let hasPasskeys = $state(false) 10 + let hasTotp = $state(false) 11 + let checkingSecurityStatus = $state(false) 12 + let securityStatusChecked = $state(false) 13 + let passkeySupported = $state(false) 14 + let clientName = $state<string | null>(null) 15 + 16 + $effect(() => { 17 + passkeySupported = window.PublicKeyCredential !== undefined 18 + }) 19 20 function getRequestUri(): string | null { 21 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') ··· 34 } 35 }) 36 37 + $effect(() => { 38 + fetchAuthRequestInfo() 39 + }) 40 + 41 + async function fetchAuthRequestInfo() { 42 + const requestUri = getRequestUri() 43 + if (!requestUri) return 44 + 45 + try { 46 + const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, { 47 + headers: { 'Accept': 'application/json' } 48 + }) 49 + if (response.ok) { 50 + const data = await response.json() 51 + if (data.login_hint && !username) { 52 + username = data.login_hint 53 + } 54 + if (data.client_name) { 55 + clientName = data.client_name 56 + } 57 + } 58 + } catch { 59 + // Ignore errors fetching auth info 60 + } 61 + } 62 + 63 + let checkTimeout: ReturnType<typeof setTimeout> | null = null 64 + 65 + $effect(() => { 66 + if (checkTimeout) { 67 + clearTimeout(checkTimeout) 68 + } 69 + hasPasskeys = false 70 + hasTotp = false 71 + securityStatusChecked = false 72 + if (username.length >= 3) { 73 + checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500) 74 + } 75 + }) 76 + 77 + async function checkUserSecurityStatus() { 78 + if (!username || checkingSecurityStatus) return 79 + checkingSecurityStatus = true 80 + try { 81 + const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`) 82 + if (response.ok) { 83 + const data = await response.json() 84 + hasPasskeys = passkeySupported && data.hasPasskeys === true 85 + hasTotp = data.hasTotp === true 86 + securityStatusChecked = true 87 + } 88 + } catch { 89 + hasPasskeys = false 90 + hasTotp = false 91 + } finally { 92 + checkingSecurityStatus = false 93 + } 94 + } 95 + 96 + 97 + async function handlePasskeyLogin() { 98 + const requestUri = getRequestUri() 99 + if (!requestUri || !username) { 100 + error = 'Missing required parameters' 101 + return 102 + } 103 + 104 + submitting = true 105 + error = null 106 + 107 + try { 108 + const startResponse = await fetch('/oauth/passkey/start', { 109 + method: 'POST', 110 + headers: { 111 + 'Content-Type': 'application/json', 112 + 'Accept': 'application/json' 113 + }, 114 + body: JSON.stringify({ 115 + request_uri: requestUri, 116 + identifier: username 117 + }) 118 + }) 119 + 120 + if (!startResponse.ok) { 121 + const data = await startResponse.json() 122 + error = data.error_description || data.error || 'Failed to start passkey login' 123 + submitting = false 124 + return 125 + } 126 + 127 + const { options } = await startResponse.json() 128 + 129 + const credential = await navigator.credentials.get({ 130 + publicKey: prepareCredentialRequestOptions(options.publicKey) 131 + }) as PublicKeyCredential | null 132 + 133 + if (!credential) { 134 + error = 'Passkey authentication was cancelled' 135 + submitting = false 136 + return 137 + } 138 + 139 + const assertionResponse = credential.response as AuthenticatorAssertionResponse 140 + const credentialData = { 141 + id: credential.id, 142 + type: credential.type, 143 + rawId: arrayBufferToBase64Url(credential.rawId), 144 + response: { 145 + clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON), 146 + authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData), 147 + signature: arrayBufferToBase64Url(assertionResponse.signature), 148 + userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null 149 + } 150 + } 151 + 152 + const finishResponse = await fetch('/oauth/passkey/finish', { 153 + method: 'POST', 154 + headers: { 155 + 'Content-Type': 'application/json', 156 + 'Accept': 'application/json' 157 + }, 158 + body: JSON.stringify({ 159 + request_uri: requestUri, 160 + credential: credentialData 161 + }) 162 + }) 163 + 164 + const data = await finishResponse.json() 165 + 166 + if (!finishResponse.ok) { 167 + error = data.error_description || data.error || 'Passkey authentication failed' 168 + submitting = false 169 + return 170 + } 171 + 172 + if (data.needs_totp) { 173 + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 174 + return 175 + } 176 + 177 + if (data.needs_2fa) { 178 + navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 179 + return 180 + } 181 + 182 + if (data.redirect_uri) { 183 + window.location.href = data.redirect_uri 184 + return 185 + } 186 + 187 + error = 'Unexpected response from server' 188 + submitting = false 189 + } catch (e) { 190 + console.error('Passkey login error:', e) 191 + if (e instanceof DOMException && e.name === 'NotAllowedError') { 192 + error = 'Passkey authentication was cancelled' 193 + } else { 194 + error = `Failed to authenticate with passkey: ${e instanceof Error ? e.message : String(e)}` 195 + } 196 + submitting = false 197 + } 198 + } 199 + 200 + function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 201 + const bytes = new Uint8Array(buffer) 202 + let binary = '' 203 + for (let i = 0; i < bytes.byteLength; i++) { 204 + binary += String.fromCharCode(bytes[i]) 205 + } 206 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 207 + } 208 + 209 + function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 210 + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 211 + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 212 + const binary = atob(padded) 213 + const bytes = new Uint8Array(binary.length) 214 + for (let i = 0; i < binary.length; i++) { 215 + bytes[i] = binary.charCodeAt(i) 216 + } 217 + return bytes.buffer 218 + } 219 + 220 + function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions { 221 + return { 222 + ...options, 223 + challenge: base64UrlToArrayBuffer(options.challenge), 224 + allowCredentials: options.allowCredentials?.map((cred: any) => ({ 225 + ...cred, 226 + id: base64UrlToArrayBuffer(cred.id) 227 + })) || [] 228 + } 229 + } 230 + 231 async function handleSubmit(e: Event) { 232 e.preventDefault() 233 const requestUri = getRequestUri() ··· 259 if (!response.ok) { 260 error = data.error_description || data.error || 'Login failed' 261 submitting = false 262 + return 263 + } 264 + 265 + if (data.needs_totp) { 266 + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 267 return 268 } 269 ··· 315 316 <div class="oauth-login-container"> 317 <h1>Sign In</h1> 318 + <p class="subtitle"> 319 + {#if clientName} 320 + Sign in to continue to <strong>{clientName}</strong> 321 + {:else} 322 + Sign in to continue to the application 323 + {/if} 324 + </p> 325 326 {#if error} 327 <div class="error">{error}</div> ··· 341 /> 342 </div> 343 344 + {#if securityStatusChecked && passkeySupported} 345 + <button 346 + type="button" 347 + class="passkey-btn" 348 + class:passkey-unavailable={!hasPasskeys} 349 + onclick={handlePasskeyLogin} 350 + disabled={submitting || !hasPasskeys || !username} 351 + title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 352 + > 353 + <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 354 + <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> 355 + <path d="M17 17v4l3-2-3-2z" /> 356 + <path d="M12 11c-4 0-6 2-6 4v4h9" /> 357 + </svg> 358 + <span class="passkey-text"> 359 + {#if submitting} 360 + Authenticating... 361 + {:else if hasPasskeys} 362 + Sign in with passkey 363 + {:else} 364 + Passkey not set up 365 + {/if} 366 + </span> 367 + </button> 368 + 369 + <div class="auth-divider"> 370 + <span>or use password</span> 371 + </div> 372 + {/if} 373 + 374 <div class="field"> 375 <label for="password">Password</label> 376 <input ··· 510 511 .submit-btn:hover:not(:disabled) { 512 background: var(--accent-hover); 513 + } 514 + 515 + .auth-divider { 516 + display: flex; 517 + align-items: center; 518 + gap: 1rem; 519 + margin: 0.5rem 0; 520 + } 521 + 522 + .auth-divider::before, 523 + .auth-divider::after { 524 + content: ''; 525 + flex: 1; 526 + height: 1px; 527 + background: var(--border-color-light); 528 + } 529 + 530 + .auth-divider span { 531 + color: var(--text-secondary); 532 + font-size: 0.875rem; 533 + } 534 + 535 + .passkey-btn { 536 + display: flex; 537 + align-items: center; 538 + justify-content: center; 539 + gap: 0.5rem; 540 + width: 100%; 541 + padding: 0.75rem; 542 + background: var(--accent); 543 + color: white; 544 + border: 1px solid var(--accent); 545 + border-radius: 4px; 546 + font-size: 1rem; 547 + cursor: pointer; 548 + transition: background-color 0.15s, border-color 0.15s, opacity 0.15s; 549 + } 550 + 551 + .passkey-btn:hover:not(:disabled) { 552 + background: var(--accent-hover); 553 + border-color: var(--accent-hover); 554 + } 555 + 556 + .passkey-btn:disabled { 557 + opacity: 0.6; 558 + cursor: not-allowed; 559 + } 560 + 561 + .passkey-btn.passkey-unavailable { 562 + background: var(--bg-secondary); 563 + color: var(--text-secondary); 564 + border-color: var(--border-color); 565 + } 566 + 567 + .passkey-icon { 568 + width: 20px; 569 + height: 20px; 570 + } 571 + 572 + .passkey-text { 573 + flex: 1; 574 + text-align: left; 575 } 576 </style>
+225
frontend/src/routes/OAuthTotp.svelte
···
··· 1 + <script lang="ts"> 2 + import { navigate } from '../lib/router.svelte' 3 + 4 + let code = $state('') 5 + let submitting = $state(false) 6 + let error = $state<string | null>(null) 7 + 8 + function getRequestUri(): string | null { 9 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 10 + return params.get('request_uri') 11 + } 12 + 13 + async function handleSubmit(e: Event) { 14 + e.preventDefault() 15 + const requestUri = getRequestUri() 16 + if (!requestUri) { 17 + error = 'Missing request_uri parameter' 18 + return 19 + } 20 + 21 + submitting = true 22 + error = null 23 + 24 + try { 25 + const response = await fetch('/oauth/authorize/2fa', { 26 + method: 'POST', 27 + headers: { 28 + 'Content-Type': 'application/json', 29 + 'Accept': 'application/json' 30 + }, 31 + body: JSON.stringify({ 32 + request_uri: requestUri, 33 + code: code.trim().toUpperCase() 34 + }) 35 + }) 36 + 37 + const data = await response.json() 38 + 39 + if (!response.ok) { 40 + error = data.error_description || data.error || 'Verification failed' 41 + submitting = false 42 + return 43 + } 44 + 45 + if (data.redirect_uri) { 46 + window.location.href = data.redirect_uri 47 + return 48 + } 49 + 50 + error = 'Unexpected response from server' 51 + submitting = false 52 + } catch { 53 + error = 'Failed to connect to server' 54 + submitting = false 55 + } 56 + } 57 + 58 + function handleCancel() { 59 + const requestUri = getRequestUri() 60 + if (requestUri) { 61 + navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`) 62 + } else { 63 + window.history.back() 64 + } 65 + } 66 + 67 + let isBackupCode = $derived(code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim())) 68 + let isTotpCode = $derived(code.trim().length === 6 && /^[0-9]+$/.test(code.trim())) 69 + let canSubmit = $derived(isBackupCode || isTotpCode) 70 + </script> 71 + 72 + <div class="oauth-totp-container"> 73 + <h1>Two-Factor Authentication</h1> 74 + <p class="subtitle"> 75 + Enter the 6-digit code from your authenticator app, or use a backup code. 76 + </p> 77 + 78 + {#if error} 79 + <div class="error">{error}</div> 80 + {/if} 81 + 82 + <form onsubmit={handleSubmit}> 83 + <div class="field"> 84 + <label for="code">Verification Code</label> 85 + <input 86 + id="code" 87 + type="text" 88 + bind:value={code} 89 + placeholder="Enter code" 90 + disabled={submitting} 91 + required 92 + maxlength="8" 93 + autocomplete="one-time-code" 94 + autocapitalize="characters" 95 + /> 96 + <p class="hint"> 97 + {#if isBackupCode} 98 + Using backup code 99 + {:else if isTotpCode} 100 + Using authenticator code 101 + {:else} 102 + 6 digits for authenticator, 8 characters for backup code 103 + {/if} 104 + </p> 105 + </div> 106 + 107 + <div class="actions"> 108 + <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 109 + Cancel 110 + </button> 111 + <button type="submit" class="submit-btn" disabled={submitting || !canSubmit}> 112 + {submitting ? 'Verifying...' : 'Verify'} 113 + </button> 114 + </div> 115 + </form> 116 + </div> 117 + 118 + <style> 119 + .oauth-totp-container { 120 + max-width: 400px; 121 + margin: 4rem auto; 122 + padding: 2rem; 123 + } 124 + 125 + h1 { 126 + margin: 0 0 0.5rem 0; 127 + } 128 + 129 + .subtitle { 130 + color: var(--text-secondary); 131 + margin: 0 0 2rem 0; 132 + } 133 + 134 + form { 135 + display: flex; 136 + flex-direction: column; 137 + gap: 1rem; 138 + } 139 + 140 + .field { 141 + display: flex; 142 + flex-direction: column; 143 + gap: 0.25rem; 144 + } 145 + 146 + label { 147 + font-size: 0.875rem; 148 + font-weight: 500; 149 + } 150 + 151 + input { 152 + padding: 0.75rem; 153 + border: 1px solid var(--border-color-light); 154 + border-radius: 4px; 155 + font-size: 1.5rem; 156 + letter-spacing: 0.25em; 157 + text-align: center; 158 + background: var(--bg-input); 159 + color: var(--text-primary); 160 + text-transform: uppercase; 161 + } 162 + 163 + input:focus { 164 + outline: none; 165 + border-color: var(--accent); 166 + } 167 + 168 + .hint { 169 + font-size: 0.75rem; 170 + color: var(--text-muted); 171 + margin: 0.25rem 0 0 0; 172 + text-align: center; 173 + } 174 + 175 + .error { 176 + padding: 0.75rem; 177 + background: var(--error-bg); 178 + border: 1px solid var(--error-border); 179 + border-radius: 4px; 180 + color: var(--error-text); 181 + margin-bottom: 1rem; 182 + } 183 + 184 + .actions { 185 + display: flex; 186 + gap: 1rem; 187 + margin-top: 0.5rem; 188 + } 189 + 190 + .actions button { 191 + flex: 1; 192 + padding: 0.75rem; 193 + border: none; 194 + border-radius: 4px; 195 + font-size: 1rem; 196 + cursor: pointer; 197 + transition: background-color 0.15s; 198 + } 199 + 200 + .actions button:disabled { 201 + opacity: 0.6; 202 + cursor: not-allowed; 203 + } 204 + 205 + .cancel-btn { 206 + background: var(--bg-secondary); 207 + color: var(--text-primary); 208 + border: 1px solid var(--border-color); 209 + } 210 + 211 + .cancel-btn:hover:not(:disabled) { 212 + background: var(--error-bg); 213 + border-color: var(--error-border); 214 + color: var(--error-text); 215 + } 216 + 217 + .submit-btn { 218 + background: var(--accent); 219 + color: white; 220 + } 221 + 222 + .submit-btn:hover:not(:disabled) { 223 + background: var(--accent-hover); 224 + } 225 + </style>
+897
frontend/src/routes/Security.svelte
···
··· 1 + <script lang="ts"> 2 + import { getAuthState } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { api, ApiError } from '../lib/api' 5 + 6 + const auth = getAuthState() 7 + let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 8 + let loading = $state(true) 9 + let totpEnabled = $state(false) 10 + let hasBackupCodes = $state(false) 11 + let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle') 12 + let qrBase64 = $state('') 13 + let totpUri = $state('') 14 + let verifyCodeRaw = $state('') 15 + let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, '')) 16 + let verifyLoading = $state(false) 17 + let backupCodes = $state<string[]>([]) 18 + let disablePassword = $state('') 19 + let disableCode = $state('') 20 + let disableLoading = $state(false) 21 + let showDisableForm = $state(false) 22 + let regenPassword = $state('') 23 + let regenCode = $state('') 24 + let regenLoading = $state(false) 25 + let showRegenForm = $state(false) 26 + 27 + interface Passkey { 28 + id: string 29 + credentialId: string 30 + friendlyName: string | null 31 + createdAt: string 32 + lastUsed: string | null 33 + } 34 + let passkeys = $state<Passkey[]>([]) 35 + let passkeysLoading = $state(true) 36 + let addingPasskey = $state(false) 37 + let newPasskeyName = $state('') 38 + let editingPasskeyId = $state<string | null>(null) 39 + let editPasskeyName = $state('') 40 + 41 + $effect(() => { 42 + if (!auth.loading && !auth.session) { 43 + navigate('/login') 44 + } 45 + }) 46 + 47 + $effect(() => { 48 + if (auth.session) { 49 + loadTotpStatus() 50 + loadPasskeys() 51 + } 52 + }) 53 + 54 + async function loadTotpStatus() { 55 + if (!auth.session) return 56 + loading = true 57 + try { 58 + const status = await api.getTotpStatus(auth.session.accessJwt) 59 + totpEnabled = status.enabled 60 + hasBackupCodes = status.hasBackupCodes 61 + } catch { 62 + showMessage('error', 'Failed to load TOTP status') 63 + } finally { 64 + loading = false 65 + } 66 + } 67 + 68 + function showMessage(type: 'success' | 'error', text: string) { 69 + message = { type, text } 70 + setTimeout(() => { 71 + if (message?.text === text) message = null 72 + }, 5000) 73 + } 74 + 75 + async function handleStartSetup() { 76 + if (!auth.session) return 77 + verifyLoading = true 78 + try { 79 + const result = await api.createTotpSecret(auth.session.accessJwt) 80 + qrBase64 = result.qrBase64 81 + totpUri = result.uri 82 + setupStep = 'qr' 83 + } catch (e) { 84 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret') 85 + } finally { 86 + verifyLoading = false 87 + } 88 + } 89 + 90 + async function handleVerifySetup(e: Event) { 91 + e.preventDefault() 92 + if (!auth.session || !verifyCode) return 93 + verifyLoading = true 94 + try { 95 + const result = await api.enableTotp(auth.session.accessJwt, verifyCode) 96 + backupCodes = result.backupCodes 97 + setupStep = 'backup' 98 + totpEnabled = true 99 + hasBackupCodes = true 100 + verifyCodeRaw = '' 101 + } catch (e) { 102 + showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.') 103 + } finally { 104 + verifyLoading = false 105 + } 106 + } 107 + 108 + function handleFinishSetup() { 109 + setupStep = 'idle' 110 + backupCodes = [] 111 + qrBase64 = '' 112 + totpUri = '' 113 + showMessage('success', 'Two-factor authentication enabled successfully') 114 + } 115 + 116 + async function handleDisable(e: Event) { 117 + e.preventDefault() 118 + if (!auth.session || !disablePassword || !disableCode) return 119 + disableLoading = true 120 + try { 121 + await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode) 122 + totpEnabled = false 123 + hasBackupCodes = false 124 + showDisableForm = false 125 + disablePassword = '' 126 + disableCode = '' 127 + showMessage('success', 'Two-factor authentication disabled') 128 + } catch (e) { 129 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP') 130 + } finally { 131 + disableLoading = false 132 + } 133 + } 134 + 135 + async function handleRegenerate(e: Event) { 136 + e.preventDefault() 137 + if (!auth.session || !regenPassword || !regenCode) return 138 + regenLoading = true 139 + try { 140 + const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode) 141 + backupCodes = result.backupCodes 142 + setupStep = 'backup' 143 + showRegenForm = false 144 + regenPassword = '' 145 + regenCode = '' 146 + } catch (e) { 147 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes') 148 + } finally { 149 + regenLoading = false 150 + } 151 + } 152 + 153 + function copyBackupCodes() { 154 + const text = backupCodes.join('\n') 155 + navigator.clipboard.writeText(text) 156 + showMessage('success', 'Backup codes copied to clipboard') 157 + } 158 + 159 + async function loadPasskeys() { 160 + if (!auth.session) return 161 + passkeysLoading = true 162 + try { 163 + const result = await api.listPasskeys(auth.session.accessJwt) 164 + passkeys = result.passkeys 165 + } catch { 166 + showMessage('error', 'Failed to load passkeys') 167 + } finally { 168 + passkeysLoading = false 169 + } 170 + } 171 + 172 + async function handleAddPasskey() { 173 + if (!auth.session) return 174 + if (!window.PublicKeyCredential) { 175 + showMessage('error', 'Passkeys are not supported in this browser') 176 + return 177 + } 178 + addingPasskey = true 179 + try { 180 + const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined) 181 + const publicKeyOptions = preparePublicKeyOptions(options) 182 + const credential = await navigator.credentials.create({ 183 + publicKey: publicKeyOptions 184 + }) 185 + if (!credential) { 186 + showMessage('error', 'Passkey creation was cancelled') 187 + return 188 + } 189 + const credentialResponse = { 190 + id: credential.id, 191 + type: credential.type, 192 + rawId: arrayBufferToBase64Url((credential as PublicKeyCredential).rawId), 193 + response: { 194 + clientDataJSON: arrayBufferToBase64Url((credential as PublicKeyCredential).response.clientDataJSON), 195 + attestationObject: arrayBufferToBase64Url(((credential as PublicKeyCredential).response as AuthenticatorAttestationResponse).attestationObject), 196 + }, 197 + } 198 + await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined) 199 + await loadPasskeys() 200 + newPasskeyName = '' 201 + showMessage('success', 'Passkey added successfully') 202 + } catch (e) { 203 + if (e instanceof DOMException && e.name === 'NotAllowedError') { 204 + showMessage('error', 'Passkey creation was cancelled') 205 + } else { 206 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey') 207 + } 208 + } finally { 209 + addingPasskey = false 210 + } 211 + } 212 + 213 + async function handleDeletePasskey(id: string) { 214 + if (!auth.session) return 215 + if (!confirm('Are you sure you want to delete this passkey?')) return 216 + try { 217 + await api.deletePasskey(auth.session.accessJwt, id) 218 + await loadPasskeys() 219 + showMessage('success', 'Passkey deleted') 220 + } catch (e) { 221 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey') 222 + } 223 + } 224 + 225 + async function handleSavePasskeyName() { 226 + if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return 227 + try { 228 + await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim()) 229 + await loadPasskeys() 230 + editingPasskeyId = null 231 + editPasskeyName = '' 232 + showMessage('success', 'Passkey renamed') 233 + } catch (e) { 234 + showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey') 235 + } 236 + } 237 + 238 + function startEditPasskey(passkey: Passkey) { 239 + editingPasskeyId = passkey.id 240 + editPasskeyName = passkey.friendlyName || '' 241 + } 242 + 243 + function cancelEditPasskey() { 244 + editingPasskeyId = null 245 + editPasskeyName = '' 246 + } 247 + 248 + function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 249 + const bytes = new Uint8Array(buffer) 250 + let binary = '' 251 + for (let i = 0; i < bytes.byteLength; i++) { 252 + binary += String.fromCharCode(bytes[i]) 253 + } 254 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 255 + } 256 + 257 + function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 258 + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 259 + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 260 + const binary = atob(padded) 261 + const bytes = new Uint8Array(binary.length) 262 + for (let i = 0; i < binary.length; i++) { 263 + bytes[i] = binary.charCodeAt(i) 264 + } 265 + return bytes.buffer 266 + } 267 + 268 + function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions { 269 + return { 270 + ...options.publicKey, 271 + challenge: base64UrlToArrayBuffer(options.publicKey.challenge), 272 + user: { 273 + ...options.publicKey.user, 274 + id: base64UrlToArrayBuffer(options.publicKey.user.id) 275 + }, 276 + excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({ 277 + ...cred, 278 + id: base64UrlToArrayBuffer(cred.id) 279 + })) || [] 280 + } 281 + } 282 + 283 + function formatDate(dateStr: string): string { 284 + return new Date(dateStr).toLocaleDateString() 285 + } 286 + </script> 287 + 288 + <div class="page"> 289 + <header> 290 + <a href="#/dashboard" class="back">&larr; Dashboard</a> 291 + <h1>Security Settings</h1> 292 + </header> 293 + 294 + {#if message} 295 + <div class="message {message.type}">{message.text}</div> 296 + {/if} 297 + 298 + {#if loading} 299 + <div class="loading">Loading...</div> 300 + {:else} 301 + <section> 302 + <h2>Two-Factor Authentication</h2> 303 + <p class="description"> 304 + Add an extra layer of security to your account using an authenticator app like Google Authenticator, Authy, or 1Password. 305 + </p> 306 + 307 + {#if setupStep === 'idle'} 308 + {#if totpEnabled} 309 + <div class="status enabled"> 310 + <span>Two-factor authentication is <strong>enabled</strong></span> 311 + </div> 312 + 313 + {#if !showDisableForm && !showRegenForm} 314 + <div class="totp-actions"> 315 + <button type="button" class="secondary" onclick={() => showRegenForm = true}> 316 + Regenerate Backup Codes 317 + </button> 318 + <button type="button" class="danger-outline" onclick={() => showDisableForm = true}> 319 + Disable 2FA 320 + </button> 321 + </div> 322 + {/if} 323 + 324 + {#if showRegenForm} 325 + <form onsubmit={handleRegenerate} class="inline-form"> 326 + <h3>Regenerate Backup Codes</h3> 327 + <p class="warning-text">This will invalidate all existing backup codes.</p> 328 + <div class="field"> 329 + <label for="regen-password">Password</label> 330 + <input 331 + id="regen-password" 332 + type="password" 333 + bind:value={regenPassword} 334 + placeholder="Enter your password" 335 + disabled={regenLoading} 336 + required 337 + /> 338 + </div> 339 + <div class="field"> 340 + <label for="regen-code">Authenticator Code</label> 341 + <input 342 + id="regen-code" 343 + type="text" 344 + bind:value={regenCode} 345 + placeholder="6-digit code" 346 + disabled={regenLoading} 347 + required 348 + maxlength="6" 349 + pattern="[0-9]{6}" 350 + inputmode="numeric" 351 + /> 352 + </div> 353 + <div class="actions"> 354 + <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}> 355 + Cancel 356 + </button> 357 + <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}> 358 + {regenLoading ? 'Regenerating...' : 'Regenerate'} 359 + </button> 360 + </div> 361 + </form> 362 + {/if} 363 + 364 + {#if showDisableForm} 365 + <form onsubmit={handleDisable} class="inline-form danger-form"> 366 + <h3>Disable Two-Factor Authentication</h3> 367 + <p class="warning-text">This will make your account less secure.</p> 368 + <div class="field"> 369 + <label for="disable-password">Password</label> 370 + <input 371 + id="disable-password" 372 + type="password" 373 + bind:value={disablePassword} 374 + placeholder="Enter your password" 375 + disabled={disableLoading} 376 + required 377 + /> 378 + </div> 379 + <div class="field"> 380 + <label for="disable-code">Authenticator Code</label> 381 + <input 382 + id="disable-code" 383 + type="text" 384 + bind:value={disableCode} 385 + placeholder="6-digit code" 386 + disabled={disableLoading} 387 + required 388 + maxlength="6" 389 + pattern="[0-9]{6}" 390 + inputmode="numeric" 391 + /> 392 + </div> 393 + <div class="actions"> 394 + <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}> 395 + Cancel 396 + </button> 397 + <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}> 398 + {disableLoading ? 'Disabling...' : 'Disable 2FA'} 399 + </button> 400 + </div> 401 + </form> 402 + {/if} 403 + {:else} 404 + <div class="status disabled"> 405 + <span>Two-factor authentication is <strong>not enabled</strong></span> 406 + </div> 407 + <button onclick={handleStartSetup} disabled={verifyLoading}> 408 + {verifyLoading ? 'Setting up...' : 'Set Up Two-Factor Authentication'} 409 + </button> 410 + {/if} 411 + {:else if setupStep === 'qr'} 412 + <div class="setup-step"> 413 + <h3>Step 1: Scan QR Code</h3> 414 + <p>Scan this QR code with your authenticator app:</p> 415 + <div class="qr-container"> 416 + <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" /> 417 + </div> 418 + <details class="manual-entry"> 419 + <summary>Can't scan? Enter manually</summary> 420 + <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 421 + </details> 422 + <button onclick={() => setupStep = 'verify'}> 423 + Next: Verify Code 424 + </button> 425 + </div> 426 + {:else if setupStep === 'verify'} 427 + <div class="setup-step"> 428 + <h3>Step 2: Verify Setup</h3> 429 + <p>Enter the 6-digit code from your authenticator app:</p> 430 + <form onsubmit={handleVerifySetup}> 431 + <div class="field"> 432 + <input 433 + type="text" 434 + bind:value={verifyCodeRaw} 435 + placeholder="000000" 436 + disabled={verifyLoading} 437 + inputmode="numeric" 438 + class="code-input" 439 + /> 440 + </div> 441 + <div class="actions"> 442 + <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}> 443 + Back 444 + </button> 445 + <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}> 446 + {verifyLoading ? 'Verifying...' : 'Verify & Enable'} 447 + </button> 448 + </div> 449 + </form> 450 + </div> 451 + {:else if setupStep === 'backup'} 452 + <div class="setup-step"> 453 + <h3>Step 3: Save Backup Codes</h3> 454 + <p class="warning-text"> 455 + Save these backup codes in a secure location. Each code can only be used once. 456 + If you lose access to your authenticator app, you'll need these to sign in. 457 + </p> 458 + <div class="backup-codes"> 459 + {#each backupCodes as code} 460 + <code class="backup-code">{code}</code> 461 + {/each} 462 + </div> 463 + <div class="actions"> 464 + <button type="button" class="secondary" onclick={copyBackupCodes}> 465 + Copy to Clipboard 466 + </button> 467 + <button onclick={handleFinishSetup}> 468 + I've Saved My Codes 469 + </button> 470 + </div> 471 + </div> 472 + {/if} 473 + </section> 474 + 475 + <section> 476 + <h2>Passkeys</h2> 477 + <p class="description"> 478 + Passkeys are a secure, passwordless way to sign in using biometrics (fingerprint or face), a security key, or your device's screen lock. 479 + </p> 480 + 481 + {#if passkeysLoading} 482 + <div class="loading">Loading passkeys...</div> 483 + {:else} 484 + {#if passkeys.length > 0} 485 + <div class="passkey-list"> 486 + {#each passkeys as passkey} 487 + <div class="passkey-item"> 488 + {#if editingPasskeyId === passkey.id} 489 + <div class="passkey-edit"> 490 + <input 491 + type="text" 492 + bind:value={editPasskeyName} 493 + placeholder="Passkey name" 494 + class="passkey-name-input" 495 + /> 496 + <div class="passkey-edit-actions"> 497 + <button type="button" class="small" onclick={handleSavePasskeyName}>Save</button> 498 + <button type="button" class="small secondary" onclick={cancelEditPasskey}>Cancel</button> 499 + </div> 500 + </div> 501 + {:else} 502 + <div class="passkey-info"> 503 + <span class="passkey-name">{passkey.friendlyName || 'Unnamed passkey'}</span> 504 + <span class="passkey-meta"> 505 + Added {formatDate(passkey.createdAt)} 506 + {#if passkey.lastUsed} 507 + &middot; Last used {formatDate(passkey.lastUsed)} 508 + {/if} 509 + </span> 510 + </div> 511 + <div class="passkey-actions"> 512 + <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 513 + Rename 514 + </button> 515 + <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 516 + Delete 517 + </button> 518 + </div> 519 + {/if} 520 + </div> 521 + {/each} 522 + </div> 523 + {:else} 524 + <div class="status disabled"> 525 + <span>No passkeys registered</span> 526 + </div> 527 + {/if} 528 + 529 + <div class="add-passkey"> 530 + <div class="field"> 531 + <label for="passkey-name">Passkey Name (optional)</label> 532 + <input 533 + id="passkey-name" 534 + type="text" 535 + bind:value={newPasskeyName} 536 + placeholder="e.g., MacBook Touch ID" 537 + disabled={addingPasskey} 538 + /> 539 + </div> 540 + <button onclick={handleAddPasskey} disabled={addingPasskey}> 541 + {addingPasskey ? 'Adding Passkey...' : 'Add a Passkey'} 542 + </button> 543 + </div> 544 + {/if} 545 + </section> 546 + {/if} 547 + </div> 548 + 549 + <style> 550 + .page { 551 + max-width: 600px; 552 + margin: 0 auto; 553 + padding: 2rem; 554 + } 555 + 556 + header { 557 + margin-bottom: 2rem; 558 + } 559 + 560 + .back { 561 + color: var(--text-secondary); 562 + text-decoration: none; 563 + font-size: 0.875rem; 564 + } 565 + 566 + .back:hover { 567 + color: var(--accent); 568 + } 569 + 570 + h1 { 571 + margin: 0.5rem 0 0 0; 572 + } 573 + 574 + .message { 575 + padding: 0.75rem; 576 + border-radius: 4px; 577 + margin-bottom: 1rem; 578 + } 579 + 580 + .message.success { 581 + background: var(--success-bg); 582 + border: 1px solid var(--success-border); 583 + color: var(--success-text); 584 + } 585 + 586 + .message.error { 587 + background: var(--error-bg); 588 + border: 1px solid var(--error-border); 589 + color: var(--error-text); 590 + } 591 + 592 + .loading { 593 + text-align: center; 594 + color: var(--text-secondary); 595 + padding: 2rem; 596 + } 597 + 598 + section { 599 + padding: 1.5rem; 600 + background: var(--bg-secondary); 601 + border-radius: 8px; 602 + margin-bottom: 1.5rem; 603 + } 604 + 605 + section h2 { 606 + margin: 0 0 0.5rem 0; 607 + font-size: 1.125rem; 608 + } 609 + 610 + .description { 611 + color: var(--text-secondary); 612 + font-size: 0.875rem; 613 + margin-bottom: 1.5rem; 614 + } 615 + 616 + .status { 617 + display: flex; 618 + align-items: center; 619 + gap: 0.5rem; 620 + padding: 0.75rem; 621 + border-radius: 4px; 622 + margin-bottom: 1rem; 623 + } 624 + 625 + .status.enabled { 626 + background: var(--success-bg); 627 + border: 1px solid var(--success-border); 628 + color: var(--success-text); 629 + } 630 + 631 + .status.disabled { 632 + background: var(--warning-bg); 633 + border: 1px solid var(--border-color); 634 + color: var(--warning-text); 635 + } 636 + 637 + .totp-actions { 638 + display: flex; 639 + gap: 0.5rem; 640 + flex-wrap: wrap; 641 + } 642 + 643 + .field { 644 + margin-bottom: 1rem; 645 + } 646 + 647 + label { 648 + display: block; 649 + font-size: 0.875rem; 650 + font-weight: 500; 651 + margin-bottom: 0.25rem; 652 + } 653 + 654 + input { 655 + width: 100%; 656 + padding: 0.75rem; 657 + border: 1px solid var(--border-color-light); 658 + border-radius: 4px; 659 + font-size: 1rem; 660 + box-sizing: border-box; 661 + background: var(--bg-input); 662 + color: var(--text-primary); 663 + } 664 + 665 + input:focus { 666 + outline: none; 667 + border-color: var(--accent); 668 + } 669 + 670 + .code-input { 671 + font-size: 1.5rem; 672 + letter-spacing: 0.5em; 673 + text-align: center; 674 + max-width: 200px; 675 + margin: 0 auto; 676 + display: block; 677 + } 678 + 679 + button { 680 + padding: 0.75rem 1.5rem; 681 + background: var(--accent); 682 + color: white; 683 + border: none; 684 + border-radius: 4px; 685 + cursor: pointer; 686 + font-size: 1rem; 687 + } 688 + 689 + button:hover:not(:disabled) { 690 + background: var(--accent-hover); 691 + } 692 + 693 + button:disabled { 694 + opacity: 0.6; 695 + cursor: not-allowed; 696 + } 697 + 698 + button.secondary { 699 + background: transparent; 700 + color: var(--text-secondary); 701 + border: 1px solid var(--border-color-light); 702 + } 703 + 704 + button.secondary:hover:not(:disabled) { 705 + background: var(--bg-card); 706 + } 707 + 708 + button.danger { 709 + background: var(--error-text); 710 + } 711 + 712 + button.danger:hover:not(:disabled) { 713 + background: #900; 714 + } 715 + 716 + button.danger-outline { 717 + background: transparent; 718 + color: var(--error-text); 719 + border: 1px solid var(--error-border); 720 + } 721 + 722 + button.danger-outline:hover:not(:disabled) { 723 + background: var(--error-bg); 724 + } 725 + 726 + .actions { 727 + display: flex; 728 + gap: 0.5rem; 729 + margin-top: 1rem; 730 + } 731 + 732 + .inline-form { 733 + margin-top: 1rem; 734 + padding: 1rem; 735 + background: var(--bg-card); 736 + border: 1px solid var(--border-color-light); 737 + border-radius: 6px; 738 + } 739 + 740 + .inline-form h3 { 741 + margin: 0 0 0.5rem 0; 742 + font-size: 1rem; 743 + } 744 + 745 + .danger-form { 746 + border-color: var(--error-border); 747 + background: var(--error-bg); 748 + } 749 + 750 + .warning-text { 751 + color: var(--error-text); 752 + font-size: 0.875rem; 753 + margin-bottom: 1rem; 754 + } 755 + 756 + .setup-step { 757 + padding: 1rem; 758 + background: var(--bg-card); 759 + border: 1px solid var(--border-color-light); 760 + border-radius: 6px; 761 + } 762 + 763 + .setup-step h3 { 764 + margin: 0 0 0.5rem 0; 765 + } 766 + 767 + .setup-step p { 768 + color: var(--text-secondary); 769 + font-size: 0.875rem; 770 + margin-bottom: 1rem; 771 + } 772 + 773 + .qr-container { 774 + display: flex; 775 + justify-content: center; 776 + margin: 1.5rem 0; 777 + } 778 + 779 + .qr-code { 780 + width: 200px; 781 + height: 200px; 782 + image-rendering: pixelated; 783 + } 784 + 785 + .manual-entry { 786 + margin-bottom: 1rem; 787 + font-size: 0.875rem; 788 + } 789 + 790 + .manual-entry summary { 791 + cursor: pointer; 792 + color: var(--accent); 793 + } 794 + 795 + .secret-code { 796 + display: block; 797 + margin-top: 0.5rem; 798 + padding: 0.5rem; 799 + background: var(--bg-input); 800 + border-radius: 4px; 801 + word-break: break-all; 802 + font-size: 0.75rem; 803 + } 804 + 805 + .backup-codes { 806 + display: grid; 807 + grid-template-columns: repeat(2, 1fr); 808 + gap: 0.5rem; 809 + margin: 1rem 0; 810 + } 811 + 812 + .backup-code { 813 + padding: 0.5rem; 814 + background: var(--bg-input); 815 + border-radius: 4px; 816 + text-align: center; 817 + font-size: 0.875rem; 818 + font-family: monospace; 819 + } 820 + 821 + .passkey-list { 822 + display: flex; 823 + flex-direction: column; 824 + gap: 0.5rem; 825 + margin-bottom: 1rem; 826 + } 827 + 828 + .passkey-item { 829 + display: flex; 830 + justify-content: space-between; 831 + align-items: center; 832 + padding: 0.75rem; 833 + background: var(--bg-card); 834 + border: 1px solid var(--border-color-light); 835 + border-radius: 6px; 836 + gap: 1rem; 837 + } 838 + 839 + .passkey-info { 840 + display: flex; 841 + flex-direction: column; 842 + gap: 0.25rem; 843 + flex: 1; 844 + min-width: 0; 845 + } 846 + 847 + .passkey-name { 848 + font-weight: 500; 849 + overflow: hidden; 850 + text-overflow: ellipsis; 851 + white-space: nowrap; 852 + } 853 + 854 + .passkey-meta { 855 + font-size: 0.75rem; 856 + color: var(--text-secondary); 857 + } 858 + 859 + .passkey-actions { 860 + display: flex; 861 + gap: 0.5rem; 862 + flex-shrink: 0; 863 + } 864 + 865 + .passkey-edit { 866 + display: flex; 867 + flex: 1; 868 + gap: 0.5rem; 869 + align-items: center; 870 + } 871 + 872 + .passkey-name-input { 873 + flex: 1; 874 + padding: 0.5rem; 875 + font-size: 0.875rem; 876 + } 877 + 878 + .passkey-edit-actions { 879 + display: flex; 880 + gap: 0.25rem; 881 + } 882 + 883 + button.small { 884 + padding: 0.375rem 0.75rem; 885 + font-size: 0.75rem; 886 + } 887 + 888 + .add-passkey { 889 + margin-top: 1rem; 890 + padding-top: 1rem; 891 + border-top: 1px solid var(--border-color-light); 892 + } 893 + 894 + .add-passkey .field { 895 + margin-bottom: 0.75rem; 896 + } 897 + </style>
+42
migrations/20251223_add_passkeys_totp.sql
···
··· 1 + CREATE TABLE user_totp ( 2 + did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, 3 + secret_encrypted BYTEA NOT NULL, 4 + encryption_version INTEGER NOT NULL DEFAULT 1, 5 + verified BOOLEAN NOT NULL DEFAULT FALSE, 6 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 + last_used TIMESTAMPTZ 8 + ); 9 + 10 + CREATE TABLE backup_codes ( 11 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 12 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 13 + code_hash TEXT NOT NULL, 14 + used_at TIMESTAMPTZ, 15 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 16 + ); 17 + CREATE INDEX idx_backup_codes_did ON backup_codes(did); 18 + 19 + CREATE TABLE passkeys ( 20 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 21 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 22 + credential_id BYTEA NOT NULL UNIQUE, 23 + public_key BYTEA NOT NULL, 24 + sign_count INTEGER NOT NULL DEFAULT 0, 25 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 26 + last_used TIMESTAMPTZ, 27 + friendly_name TEXT, 28 + aaguid BYTEA, 29 + transports TEXT[] 30 + ); 31 + CREATE INDEX idx_passkeys_did ON passkeys(did); 32 + 33 + CREATE TABLE webauthn_challenges ( 34 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 35 + did TEXT NOT NULL, 36 + challenge BYTEA NOT NULL, 37 + challenge_type TEXT NOT NULL, 38 + state_json TEXT NOT NULL, 39 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 40 + expires_at TIMESTAMPTZ NOT NULL 41 + ); 42 + CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
+10
src/api/server/mod.rs
··· 3 pub mod email; 4 pub mod invite; 5 pub mod meta; 6 pub mod password; 7 pub mod service_auth; 8 pub mod session; 9 pub mod signing_key; 10 11 pub use account_status::{ 12 activate_account, check_account_status, deactivate_account, delete_account, ··· 16 pub use email::{confirm_email, request_email_update, update_email}; 17 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 18 pub use meta::{describe_server, health, robots_txt}; 19 pub use password::{change_password, request_password_reset, reset_password}; 20 pub use service_auth::get_service_auth; 21 pub use session::{ ··· 23 resend_verification, revoke_session, 24 }; 25 pub use signing_key::reserve_signing_key;
··· 3 pub mod email; 4 pub mod invite; 5 pub mod meta; 6 + pub mod passkeys; 7 pub mod password; 8 pub mod service_auth; 9 pub mod session; 10 pub mod signing_key; 11 + pub mod totp; 12 13 pub use account_status::{ 14 activate_account, check_account_status, deactivate_account, delete_account, ··· 18 pub use email::{confirm_email, request_email_update, update_email}; 19 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 20 pub use meta::{describe_server, health, robots_txt}; 21 + pub use passkeys::{ 22 + delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys, 23 + start_passkey_registration, update_passkey, 24 + }; 25 pub use password::{change_password, request_password_reset, reset_password}; 26 pub use service_auth::get_service_auth; 27 pub use session::{ ··· 29 resend_verification, revoke_session, 30 }; 31 pub use signing_key::reserve_signing_key; 32 + pub use totp::{ 33 + create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled, 34 + regenerate_backup_codes, verify_totp_or_backup_for_user, 35 + };
+377
src/api/server/passkeys.rs
···
··· 1 + use crate::auth::BearerAuth; 2 + use crate::auth::webauthn::{ 3 + self, WebAuthnConfig, delete_passkey as db_delete_passkey, delete_registration_state, 4 + get_passkeys_for_user, load_registration_state, save_passkey, save_registration_state, 5 + update_passkey_name as db_update_passkey_name, 6 + }; 7 + use crate::state::AppState; 8 + use axum::{ 9 + Json, 10 + extract::State, 11 + http::StatusCode, 12 + response::{IntoResponse, Response}, 13 + }; 14 + use serde::{Deserialize, Serialize}; 15 + use serde_json::json; 16 + use tracing::{error, info, warn}; 17 + use webauthn_rs::prelude::*; 18 + 19 + fn get_webauthn() -> Result<WebAuthnConfig, (StatusCode, Json<serde_json::Value>)> { 20 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 21 + WebAuthnConfig::new(&hostname).map_err(|e| { 22 + error!("Failed to create WebAuthn config: {}", e); 23 + ( 24 + StatusCode::INTERNAL_SERVER_ERROR, 25 + Json(json!({"error": "InternalError", "message": "WebAuthn configuration failed"})), 26 + ) 27 + }) 28 + } 29 + 30 + #[derive(Deserialize)] 31 + #[serde(rename_all = "camelCase")] 32 + pub struct StartRegistrationInput { 33 + pub friendly_name: Option<String>, 34 + } 35 + 36 + #[derive(Serialize)] 37 + #[serde(rename_all = "camelCase")] 38 + pub struct StartRegistrationResponse { 39 + pub options: serde_json::Value, 40 + } 41 + 42 + pub async fn start_passkey_registration( 43 + State(state): State<AppState>, 44 + auth: BearerAuth, 45 + Json(input): Json<StartRegistrationInput>, 46 + ) -> Response { 47 + let webauthn = match get_webauthn() { 48 + Ok(w) => w, 49 + Err(e) => return e.into_response(), 50 + }; 51 + 52 + let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", auth.0.did) 53 + .fetch_optional(&state.db) 54 + .await; 55 + 56 + let handle = match user { 57 + Ok(Some(row)) => row.handle, 58 + Ok(None) => { 59 + return ( 60 + StatusCode::NOT_FOUND, 61 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 62 + ) 63 + .into_response(); 64 + } 65 + Err(e) => { 66 + error!("DB error fetching user: {:?}", e); 67 + return ( 68 + StatusCode::INTERNAL_SERVER_ERROR, 69 + Json(json!({"error": "InternalError"})), 70 + ) 71 + .into_response(); 72 + } 73 + }; 74 + 75 + let existing_passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await { 76 + Ok(passkeys) => passkeys, 77 + Err(e) => { 78 + error!("DB error fetching existing passkeys: {:?}", e); 79 + return ( 80 + StatusCode::INTERNAL_SERVER_ERROR, 81 + Json(json!({"error": "InternalError"})), 82 + ) 83 + .into_response(); 84 + } 85 + }; 86 + 87 + let exclude_credentials: Vec<CredentialID> = existing_passkeys 88 + .iter() 89 + .map(|p| CredentialID::from(p.credential_id.clone())) 90 + .collect(); 91 + 92 + let display_name = input.friendly_name.as_deref().unwrap_or(&handle); 93 + 94 + let (ccr, reg_state) = match webauthn.start_registration( 95 + &auth.0.did, 96 + &handle, 97 + display_name, 98 + exclude_credentials, 99 + ) { 100 + Ok(result) => result, 101 + Err(e) => { 102 + error!("Failed to start passkey registration: {}", e); 103 + return ( 104 + StatusCode::INTERNAL_SERVER_ERROR, 105 + Json(json!({"error": "InternalError", "message": "Failed to start registration"})), 106 + ) 107 + .into_response(); 108 + } 109 + }; 110 + 111 + if let Err(e) = save_registration_state(&state.db, &auth.0.did, &reg_state).await { 112 + error!("Failed to save registration state: {:?}", e); 113 + return ( 114 + StatusCode::INTERNAL_SERVER_ERROR, 115 + Json(json!({"error": "InternalError"})), 116 + ) 117 + .into_response(); 118 + } 119 + 120 + let options = serde_json::to_value(&ccr).unwrap_or(json!({})); 121 + 122 + info!(did = %auth.0.did, "Passkey registration started"); 123 + 124 + Json(StartRegistrationResponse { options }).into_response() 125 + } 126 + 127 + #[derive(Deserialize)] 128 + #[serde(rename_all = "camelCase")] 129 + pub struct FinishRegistrationInput { 130 + pub credential: serde_json::Value, 131 + pub friendly_name: Option<String>, 132 + } 133 + 134 + #[derive(Serialize)] 135 + #[serde(rename_all = "camelCase")] 136 + pub struct FinishRegistrationResponse { 137 + pub id: String, 138 + pub credential_id: String, 139 + } 140 + 141 + pub async fn finish_passkey_registration( 142 + State(state): State<AppState>, 143 + auth: BearerAuth, 144 + Json(input): Json<FinishRegistrationInput>, 145 + ) -> Response { 146 + let webauthn = match get_webauthn() { 147 + Ok(w) => w, 148 + Err(e) => return e.into_response(), 149 + }; 150 + 151 + let reg_state = match load_registration_state(&state.db, &auth.0.did).await { 152 + Ok(Some(state)) => state, 153 + Ok(None) => { 154 + return ( 155 + StatusCode::BAD_REQUEST, 156 + Json(json!({ 157 + "error": "NoRegistrationInProgress", 158 + "message": "No registration in progress. Call startPasskeyRegistration first." 159 + })), 160 + ) 161 + .into_response(); 162 + } 163 + Err(e) => { 164 + error!("DB error loading registration state: {:?}", e); 165 + return ( 166 + StatusCode::INTERNAL_SERVER_ERROR, 167 + Json(json!({"error": "InternalError"})), 168 + ) 169 + .into_response(); 170 + } 171 + }; 172 + 173 + let credential: RegisterPublicKeyCredential = match serde_json::from_value(input.credential) { 174 + Ok(c) => c, 175 + Err(e) => { 176 + warn!("Failed to parse credential: {:?}", e); 177 + return ( 178 + StatusCode::BAD_REQUEST, 179 + Json(json!({ 180 + "error": "InvalidCredential", 181 + "message": "Failed to parse credential response" 182 + })), 183 + ) 184 + .into_response(); 185 + } 186 + }; 187 + 188 + let passkey = match webauthn.finish_registration(&credential, &reg_state) { 189 + Ok(pk) => pk, 190 + Err(e) => { 191 + warn!("Failed to finish passkey registration: {}", e); 192 + return ( 193 + StatusCode::BAD_REQUEST, 194 + Json(json!({ 195 + "error": "RegistrationFailed", 196 + "message": "Failed to verify passkey registration" 197 + })), 198 + ) 199 + .into_response(); 200 + } 201 + }; 202 + 203 + let passkey_id = match save_passkey( 204 + &state.db, 205 + &auth.0.did, 206 + &passkey, 207 + input.friendly_name.as_deref(), 208 + ) 209 + .await 210 + { 211 + Ok(id) => id, 212 + Err(e) => { 213 + error!("Failed to save passkey: {:?}", e); 214 + return ( 215 + StatusCode::INTERNAL_SERVER_ERROR, 216 + Json(json!({"error": "InternalError"})), 217 + ) 218 + .into_response(); 219 + } 220 + }; 221 + 222 + if let Err(e) = delete_registration_state(&state.db, &auth.0.did).await { 223 + warn!("Failed to delete registration state: {:?}", e); 224 + } 225 + 226 + let credential_id_base64 = base64::Engine::encode( 227 + &base64::engine::general_purpose::URL_SAFE_NO_PAD, 228 + passkey.cred_id(), 229 + ); 230 + 231 + info!(did = %auth.0.did, passkey_id = %passkey_id, "Passkey registered"); 232 + 233 + Json(FinishRegistrationResponse { 234 + id: passkey_id.to_string(), 235 + credential_id: credential_id_base64, 236 + }) 237 + .into_response() 238 + } 239 + 240 + #[derive(Serialize)] 241 + #[serde(rename_all = "camelCase")] 242 + pub struct PasskeyInfo { 243 + pub id: String, 244 + pub credential_id: String, 245 + pub friendly_name: Option<String>, 246 + pub created_at: String, 247 + pub last_used: Option<String>, 248 + } 249 + 250 + #[derive(Serialize)] 251 + #[serde(rename_all = "camelCase")] 252 + pub struct ListPasskeysResponse { 253 + pub passkeys: Vec<PasskeyInfo>, 254 + } 255 + 256 + pub async fn list_passkeys(State(state): State<AppState>, auth: BearerAuth) -> Response { 257 + let passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await { 258 + Ok(pks) => pks, 259 + Err(e) => { 260 + error!("DB error fetching passkeys: {:?}", e); 261 + return ( 262 + StatusCode::INTERNAL_SERVER_ERROR, 263 + Json(json!({"error": "InternalError"})), 264 + ) 265 + .into_response(); 266 + } 267 + }; 268 + 269 + let passkey_infos: Vec<PasskeyInfo> = passkeys 270 + .into_iter() 271 + .map(|pk| PasskeyInfo { 272 + id: pk.id.to_string(), 273 + credential_id: pk.credential_id_base64(), 274 + friendly_name: pk.friendly_name, 275 + created_at: pk.created_at.to_rfc3339(), 276 + last_used: pk.last_used.map(|dt| dt.to_rfc3339()), 277 + }) 278 + .collect(); 279 + 280 + Json(ListPasskeysResponse { 281 + passkeys: passkey_infos, 282 + }) 283 + .into_response() 284 + } 285 + 286 + #[derive(Deserialize)] 287 + #[serde(rename_all = "camelCase")] 288 + pub struct DeletePasskeyInput { 289 + pub id: String, 290 + } 291 + 292 + pub async fn delete_passkey( 293 + State(state): State<AppState>, 294 + auth: BearerAuth, 295 + Json(input): Json<DeletePasskeyInput>, 296 + ) -> Response { 297 + let id: uuid::Uuid = match input.id.parse() { 298 + Ok(id) => id, 299 + Err(_) => { 300 + return ( 301 + StatusCode::BAD_REQUEST, 302 + Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})), 303 + ) 304 + .into_response(); 305 + } 306 + }; 307 + 308 + match db_delete_passkey(&state.db, id, &auth.0.did).await { 309 + Ok(true) => { 310 + info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted"); 311 + (StatusCode::OK, Json(json!({}))).into_response() 312 + } 313 + Ok(false) => ( 314 + StatusCode::NOT_FOUND, 315 + Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})), 316 + ) 317 + .into_response(), 318 + Err(e) => { 319 + error!("DB error deleting passkey: {:?}", e); 320 + ( 321 + StatusCode::INTERNAL_SERVER_ERROR, 322 + Json(json!({"error": "InternalError"})), 323 + ) 324 + .into_response() 325 + } 326 + } 327 + } 328 + 329 + #[derive(Deserialize)] 330 + #[serde(rename_all = "camelCase")] 331 + pub struct UpdatePasskeyInput { 332 + pub id: String, 333 + pub friendly_name: String, 334 + } 335 + 336 + pub async fn update_passkey( 337 + State(state): State<AppState>, 338 + auth: BearerAuth, 339 + Json(input): Json<UpdatePasskeyInput>, 340 + ) -> Response { 341 + let id: uuid::Uuid = match input.id.parse() { 342 + Ok(id) => id, 343 + Err(_) => { 344 + return ( 345 + StatusCode::BAD_REQUEST, 346 + Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})), 347 + ) 348 + .into_response(); 349 + } 350 + }; 351 + 352 + match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await { 353 + Ok(true) => { 354 + info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed"); 355 + (StatusCode::OK, Json(json!({}))).into_response() 356 + } 357 + Ok(false) => ( 358 + StatusCode::NOT_FOUND, 359 + Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})), 360 + ) 361 + .into_response(), 362 + Err(e) => { 363 + error!("DB error updating passkey: {:?}", e); 364 + ( 365 + StatusCode::INTERNAL_SERVER_ERROR, 366 + Json(json!({"error": "InternalError"})), 367 + ) 368 + .into_response() 369 + } 370 + } 371 + } 372 + 373 + pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool { 374 + webauthn::has_passkeys(&state.db, did) 375 + .await 376 + .unwrap_or(false) 377 + }
+749
src/api/server/totp.rs
···
··· 1 + use crate::auth::BearerAuth; 2 + use crate::auth::totp::{ 3 + decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64, 4 + generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format, 5 + verify_backup_code, verify_totp_code, 6 + }; 7 + use crate::state::AppState; 8 + use axum::{ 9 + Json, 10 + extract::State, 11 + http::StatusCode, 12 + response::{IntoResponse, Response}, 13 + }; 14 + use chrono::Utc; 15 + use serde::{Deserialize, Serialize}; 16 + use serde_json::json; 17 + use tracing::{error, info, warn}; 18 + 19 + const ENCRYPTION_VERSION: i32 = 1; 20 + 21 + #[derive(Serialize)] 22 + #[serde(rename_all = "camelCase")] 23 + pub struct CreateTotpSecretResponse { 24 + pub secret: String, 25 + pub uri: String, 26 + pub qr_base64: String, 27 + } 28 + 29 + pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response { 30 + let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did) 31 + .fetch_optional(&state.db) 32 + .await; 33 + 34 + if let Ok(Some(true)) = existing { 35 + return ( 36 + StatusCode::CONFLICT, 37 + Json(json!({ 38 + "error": "TotpAlreadyEnabled", 39 + "message": "TOTP is already enabled for this account" 40 + })), 41 + ) 42 + .into_response(); 43 + } 44 + 45 + let secret = generate_totp_secret(); 46 + 47 + let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did) 48 + .fetch_optional(&state.db) 49 + .await; 50 + 51 + let handle = match handle { 52 + Ok(Some(h)) => h, 53 + Ok(None) => { 54 + return ( 55 + StatusCode::NOT_FOUND, 56 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 57 + ) 58 + .into_response(); 59 + } 60 + Err(e) => { 61 + error!("DB error fetching handle: {:?}", e); 62 + return ( 63 + StatusCode::INTERNAL_SERVER_ERROR, 64 + Json(json!({"error": "InternalError"})), 65 + ) 66 + .into_response(); 67 + } 68 + }; 69 + 70 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 71 + let uri = generate_totp_uri(&secret, &handle, &hostname); 72 + 73 + let qr_code = match generate_qr_png_base64(&secret, &handle, &hostname) { 74 + Ok(qr) => qr, 75 + Err(e) => { 76 + error!("Failed to generate QR code: {:?}", e); 77 + return ( 78 + StatusCode::INTERNAL_SERVER_ERROR, 79 + Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})), 80 + ) 81 + .into_response(); 82 + } 83 + }; 84 + 85 + let encrypted_secret = match encrypt_totp_secret(&secret) { 86 + Ok(enc) => enc, 87 + Err(e) => { 88 + error!("Failed to encrypt TOTP secret: {:?}", e); 89 + return ( 90 + StatusCode::INTERNAL_SERVER_ERROR, 91 + Json(json!({"error": "InternalError"})), 92 + ) 93 + .into_response(); 94 + } 95 + }; 96 + 97 + let result = sqlx::query!( 98 + r#" 99 + INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at) 100 + VALUES ($1, $2, $3, false, NOW()) 101 + ON CONFLICT (did) DO UPDATE SET 102 + secret_encrypted = $2, 103 + encryption_version = $3, 104 + verified = false, 105 + created_at = NOW(), 106 + last_used = NULL 107 + "#, 108 + auth.0.did, 109 + encrypted_secret, 110 + ENCRYPTION_VERSION 111 + ) 112 + .execute(&state.db) 113 + .await; 114 + 115 + if let Err(e) = result { 116 + error!("Failed to store TOTP secret: {:?}", e); 117 + return ( 118 + StatusCode::INTERNAL_SERVER_ERROR, 119 + Json(json!({"error": "InternalError"})), 120 + ) 121 + .into_response(); 122 + } 123 + 124 + let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret); 125 + 126 + info!(did = %auth.0.did, "TOTP secret created (pending verification)"); 127 + 128 + Json(CreateTotpSecretResponse { 129 + secret: secret_base32, 130 + uri, 131 + qr_base64: qr_code, 132 + }) 133 + .into_response() 134 + } 135 + 136 + #[derive(Deserialize)] 137 + pub struct EnableTotpInput { 138 + pub code: String, 139 + } 140 + 141 + #[derive(Serialize)] 142 + #[serde(rename_all = "camelCase")] 143 + pub struct EnableTotpResponse { 144 + pub backup_codes: Vec<String>, 145 + } 146 + 147 + pub async fn enable_totp( 148 + State(state): State<AppState>, 149 + auth: BearerAuth, 150 + Json(input): Json<EnableTotpInput>, 151 + ) -> Response { 152 + let totp_row = sqlx::query!( 153 + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", 154 + auth.0.did 155 + ) 156 + .fetch_optional(&state.db) 157 + .await; 158 + 159 + let totp_row = match totp_row { 160 + Ok(Some(row)) => row, 161 + Ok(None) => { 162 + return ( 163 + StatusCode::BAD_REQUEST, 164 + Json(json!({ 165 + "error": "TotpNotSetup", 166 + "message": "Please call createTotpSecret first" 167 + })), 168 + ) 169 + .into_response(); 170 + } 171 + Err(e) => { 172 + error!("DB error fetching TOTP: {:?}", e); 173 + return ( 174 + StatusCode::INTERNAL_SERVER_ERROR, 175 + Json(json!({"error": "InternalError"})), 176 + ) 177 + .into_response(); 178 + } 179 + }; 180 + 181 + if totp_row.verified { 182 + return ( 183 + StatusCode::CONFLICT, 184 + Json(json!({ 185 + "error": "TotpAlreadyEnabled", 186 + "message": "TOTP is already enabled" 187 + })), 188 + ) 189 + .into_response(); 190 + } 191 + 192 + let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) 193 + { 194 + Ok(s) => s, 195 + Err(e) => { 196 + error!("Failed to decrypt TOTP secret: {:?}", e); 197 + return ( 198 + StatusCode::INTERNAL_SERVER_ERROR, 199 + Json(json!({"error": "InternalError"})), 200 + ) 201 + .into_response(); 202 + } 203 + }; 204 + 205 + let code = input.code.trim(); 206 + if !verify_totp_code(&secret, code) { 207 + return ( 208 + StatusCode::UNAUTHORIZED, 209 + Json(json!({ 210 + "error": "InvalidCode", 211 + "message": "Invalid verification code" 212 + })), 213 + ) 214 + .into_response(); 215 + } 216 + 217 + let backup_codes = generate_backup_codes(); 218 + let mut tx = match state.db.begin().await { 219 + Ok(tx) => tx, 220 + Err(e) => { 221 + error!("Failed to begin transaction: {:?}", e); 222 + return ( 223 + StatusCode::INTERNAL_SERVER_ERROR, 224 + Json(json!({"error": "InternalError"})), 225 + ) 226 + .into_response(); 227 + } 228 + }; 229 + 230 + if let Err(e) = sqlx::query!( 231 + "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1", 232 + auth.0.did 233 + ) 234 + .execute(&mut *tx) 235 + .await 236 + { 237 + error!("Failed to enable TOTP: {:?}", e); 238 + return ( 239 + StatusCode::INTERNAL_SERVER_ERROR, 240 + Json(json!({"error": "InternalError"})), 241 + ) 242 + .into_response(); 243 + } 244 + 245 + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) 246 + .execute(&mut *tx) 247 + .await 248 + { 249 + error!("Failed to clear old backup codes: {:?}", e); 250 + return ( 251 + StatusCode::INTERNAL_SERVER_ERROR, 252 + Json(json!({"error": "InternalError"})), 253 + ) 254 + .into_response(); 255 + } 256 + 257 + for code in &backup_codes { 258 + let hash = match hash_backup_code(code) { 259 + Ok(h) => h, 260 + Err(e) => { 261 + error!("Failed to hash backup code: {:?}", e); 262 + return ( 263 + StatusCode::INTERNAL_SERVER_ERROR, 264 + Json(json!({"error": "InternalError"})), 265 + ) 266 + .into_response(); 267 + } 268 + }; 269 + 270 + if let Err(e) = sqlx::query!( 271 + "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", 272 + auth.0.did, 273 + hash 274 + ) 275 + .execute(&mut *tx) 276 + .await 277 + { 278 + error!("Failed to store backup code: {:?}", e); 279 + return ( 280 + StatusCode::INTERNAL_SERVER_ERROR, 281 + Json(json!({"error": "InternalError"})), 282 + ) 283 + .into_response(); 284 + } 285 + } 286 + 287 + if let Err(e) = tx.commit().await { 288 + error!("Failed to commit transaction: {:?}", e); 289 + return ( 290 + StatusCode::INTERNAL_SERVER_ERROR, 291 + Json(json!({"error": "InternalError"})), 292 + ) 293 + .into_response(); 294 + } 295 + 296 + info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len()); 297 + 298 + Json(EnableTotpResponse { backup_codes }).into_response() 299 + } 300 + 301 + #[derive(Deserialize)] 302 + pub struct DisableTotpInput { 303 + pub password: String, 304 + pub code: String, 305 + } 306 + 307 + pub async fn disable_totp( 308 + State(state): State<AppState>, 309 + auth: BearerAuth, 310 + Json(input): Json<DisableTotpInput>, 311 + ) -> Response { 312 + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 313 + .fetch_optional(&state.db) 314 + .await; 315 + 316 + let password_hash = match user { 317 + Ok(Some(row)) => row.password_hash, 318 + Ok(None) => { 319 + return ( 320 + StatusCode::NOT_FOUND, 321 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 322 + ) 323 + .into_response(); 324 + } 325 + Err(e) => { 326 + error!("DB error fetching user: {:?}", e); 327 + return ( 328 + StatusCode::INTERNAL_SERVER_ERROR, 329 + Json(json!({"error": "InternalError"})), 330 + ) 331 + .into_response(); 332 + } 333 + }; 334 + 335 + let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); 336 + if !password_valid { 337 + return ( 338 + StatusCode::UNAUTHORIZED, 339 + Json(json!({ 340 + "error": "InvalidPassword", 341 + "message": "Password is incorrect" 342 + })), 343 + ) 344 + .into_response(); 345 + } 346 + 347 + let totp_row = sqlx::query!( 348 + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", 349 + auth.0.did 350 + ) 351 + .fetch_optional(&state.db) 352 + .await; 353 + 354 + let totp_row = match totp_row { 355 + Ok(Some(row)) if row.verified => row, 356 + Ok(Some(_)) | Ok(None) => { 357 + return ( 358 + StatusCode::BAD_REQUEST, 359 + Json(json!({ 360 + "error": "TotpNotEnabled", 361 + "message": "TOTP is not enabled for this account" 362 + })), 363 + ) 364 + .into_response(); 365 + } 366 + Err(e) => { 367 + error!("DB error fetching TOTP: {:?}", e); 368 + return ( 369 + StatusCode::INTERNAL_SERVER_ERROR, 370 + Json(json!({"error": "InternalError"})), 371 + ) 372 + .into_response(); 373 + } 374 + }; 375 + 376 + let code = input.code.trim(); 377 + let code_valid = if is_backup_code_format(code) { 378 + verify_backup_code_for_user(&state, &auth.0.did, code).await 379 + } else { 380 + let secret = 381 + match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) { 382 + Ok(s) => s, 383 + Err(e) => { 384 + error!("Failed to decrypt TOTP secret: {:?}", e); 385 + return ( 386 + StatusCode::INTERNAL_SERVER_ERROR, 387 + Json(json!({"error": "InternalError"})), 388 + ) 389 + .into_response(); 390 + } 391 + }; 392 + verify_totp_code(&secret, code) 393 + }; 394 + 395 + if !code_valid { 396 + return ( 397 + StatusCode::UNAUTHORIZED, 398 + Json(json!({ 399 + "error": "InvalidCode", 400 + "message": "Invalid verification code" 401 + })), 402 + ) 403 + .into_response(); 404 + } 405 + 406 + let mut tx = match state.db.begin().await { 407 + Ok(tx) => tx, 408 + Err(e) => { 409 + error!("Failed to begin transaction: {:?}", e); 410 + return ( 411 + StatusCode::INTERNAL_SERVER_ERROR, 412 + Json(json!({"error": "InternalError"})), 413 + ) 414 + .into_response(); 415 + } 416 + }; 417 + 418 + if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did) 419 + .execute(&mut *tx) 420 + .await 421 + { 422 + error!("Failed to delete TOTP: {:?}", e); 423 + return ( 424 + StatusCode::INTERNAL_SERVER_ERROR, 425 + Json(json!({"error": "InternalError"})), 426 + ) 427 + .into_response(); 428 + } 429 + 430 + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) 431 + .execute(&mut *tx) 432 + .await 433 + { 434 + error!("Failed to delete backup codes: {:?}", e); 435 + return ( 436 + StatusCode::INTERNAL_SERVER_ERROR, 437 + Json(json!({"error": "InternalError"})), 438 + ) 439 + .into_response(); 440 + } 441 + 442 + if let Err(e) = tx.commit().await { 443 + error!("Failed to commit transaction: {:?}", e); 444 + return ( 445 + StatusCode::INTERNAL_SERVER_ERROR, 446 + Json(json!({"error": "InternalError"})), 447 + ) 448 + .into_response(); 449 + } 450 + 451 + info!(did = %auth.0.did, "TOTP disabled"); 452 + 453 + (StatusCode::OK, Json(json!({}))).into_response() 454 + } 455 + 456 + #[derive(Serialize)] 457 + #[serde(rename_all = "camelCase")] 458 + pub struct GetTotpStatusResponse { 459 + pub enabled: bool, 460 + pub has_backup_codes: bool, 461 + pub backup_codes_remaining: i64, 462 + } 463 + 464 + pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response { 465 + let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did) 466 + .fetch_optional(&state.db) 467 + .await; 468 + 469 + let enabled = match totp_row { 470 + Ok(Some(row)) => row.verified, 471 + Ok(None) => false, 472 + Err(e) => { 473 + error!("DB error fetching TOTP status: {:?}", e); 474 + return ( 475 + StatusCode::INTERNAL_SERVER_ERROR, 476 + Json(json!({"error": "InternalError"})), 477 + ) 478 + .into_response(); 479 + } 480 + }; 481 + 482 + let backup_count_row = sqlx::query!( 483 + "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL", 484 + auth.0.did 485 + ) 486 + .fetch_one(&state.db) 487 + .await; 488 + 489 + let backup_count = backup_count_row.map(|r| r.count.unwrap_or(0)).unwrap_or(0); 490 + 491 + Json(GetTotpStatusResponse { 492 + enabled, 493 + has_backup_codes: backup_count > 0, 494 + backup_codes_remaining: backup_count, 495 + }) 496 + .into_response() 497 + } 498 + 499 + #[derive(Deserialize)] 500 + pub struct RegenerateBackupCodesInput { 501 + pub password: String, 502 + pub code: String, 503 + } 504 + 505 + #[derive(Serialize)] 506 + #[serde(rename_all = "camelCase")] 507 + pub struct RegenerateBackupCodesResponse { 508 + pub backup_codes: Vec<String>, 509 + } 510 + 511 + pub async fn regenerate_backup_codes( 512 + State(state): State<AppState>, 513 + auth: BearerAuth, 514 + Json(input): Json<RegenerateBackupCodesInput>, 515 + ) -> Response { 516 + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 517 + .fetch_optional(&state.db) 518 + .await; 519 + 520 + let password_hash = match user { 521 + Ok(Some(row)) => row.password_hash, 522 + Ok(None) => { 523 + return ( 524 + StatusCode::NOT_FOUND, 525 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 526 + ) 527 + .into_response(); 528 + } 529 + Err(e) => { 530 + error!("DB error fetching user: {:?}", e); 531 + return ( 532 + StatusCode::INTERNAL_SERVER_ERROR, 533 + Json(json!({"error": "InternalError"})), 534 + ) 535 + .into_response(); 536 + } 537 + }; 538 + 539 + let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); 540 + if !password_valid { 541 + return ( 542 + StatusCode::UNAUTHORIZED, 543 + Json(json!({ 544 + "error": "InvalidPassword", 545 + "message": "Password is incorrect" 546 + })), 547 + ) 548 + .into_response(); 549 + } 550 + 551 + let totp_row = sqlx::query!( 552 + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", 553 + auth.0.did 554 + ) 555 + .fetch_optional(&state.db) 556 + .await; 557 + 558 + let totp_row = match totp_row { 559 + Ok(Some(row)) if row.verified => row, 560 + Ok(Some(_)) | Ok(None) => { 561 + return ( 562 + StatusCode::BAD_REQUEST, 563 + Json(json!({ 564 + "error": "TotpNotEnabled", 565 + "message": "TOTP must be enabled to regenerate backup codes" 566 + })), 567 + ) 568 + .into_response(); 569 + } 570 + Err(e) => { 571 + error!("DB error fetching TOTP: {:?}", e); 572 + return ( 573 + StatusCode::INTERNAL_SERVER_ERROR, 574 + Json(json!({"error": "InternalError"})), 575 + ) 576 + .into_response(); 577 + } 578 + }; 579 + 580 + let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) 581 + { 582 + Ok(s) => s, 583 + Err(e) => { 584 + error!("Failed to decrypt TOTP secret: {:?}", e); 585 + return ( 586 + StatusCode::INTERNAL_SERVER_ERROR, 587 + Json(json!({"error": "InternalError"})), 588 + ) 589 + .into_response(); 590 + } 591 + }; 592 + 593 + let code = input.code.trim(); 594 + if !verify_totp_code(&secret, code) { 595 + return ( 596 + StatusCode::UNAUTHORIZED, 597 + Json(json!({ 598 + "error": "InvalidCode", 599 + "message": "Invalid verification code" 600 + })), 601 + ) 602 + .into_response(); 603 + } 604 + 605 + let backup_codes = generate_backup_codes(); 606 + let mut tx = match state.db.begin().await { 607 + Ok(tx) => tx, 608 + Err(e) => { 609 + error!("Failed to begin transaction: {:?}", e); 610 + return ( 611 + StatusCode::INTERNAL_SERVER_ERROR, 612 + Json(json!({"error": "InternalError"})), 613 + ) 614 + .into_response(); 615 + } 616 + }; 617 + 618 + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) 619 + .execute(&mut *tx) 620 + .await 621 + { 622 + error!("Failed to clear old backup codes: {:?}", e); 623 + return ( 624 + StatusCode::INTERNAL_SERVER_ERROR, 625 + Json(json!({"error": "InternalError"})), 626 + ) 627 + .into_response(); 628 + } 629 + 630 + for code in &backup_codes { 631 + let hash = match hash_backup_code(code) { 632 + Ok(h) => h, 633 + Err(e) => { 634 + error!("Failed to hash backup code: {:?}", e); 635 + return ( 636 + StatusCode::INTERNAL_SERVER_ERROR, 637 + Json(json!({"error": "InternalError"})), 638 + ) 639 + .into_response(); 640 + } 641 + }; 642 + 643 + if let Err(e) = sqlx::query!( 644 + "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", 645 + auth.0.did, 646 + hash 647 + ) 648 + .execute(&mut *tx) 649 + .await 650 + { 651 + error!("Failed to store backup code: {:?}", e); 652 + return ( 653 + StatusCode::INTERNAL_SERVER_ERROR, 654 + Json(json!({"error": "InternalError"})), 655 + ) 656 + .into_response(); 657 + } 658 + } 659 + 660 + if let Err(e) = tx.commit().await { 661 + error!("Failed to commit transaction: {:?}", e); 662 + return ( 663 + StatusCode::INTERNAL_SERVER_ERROR, 664 + Json(json!({"error": "InternalError"})), 665 + ) 666 + .into_response(); 667 + } 668 + 669 + info!(did = %auth.0.did, "Backup codes regenerated"); 670 + 671 + Json(RegenerateBackupCodesResponse { backup_codes }).into_response() 672 + } 673 + 674 + async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> bool { 675 + let code = code.trim().to_uppercase(); 676 + 677 + let backup_codes = sqlx::query!( 678 + "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL", 679 + did 680 + ) 681 + .fetch_all(&state.db) 682 + .await; 683 + 684 + let backup_codes = match backup_codes { 685 + Ok(codes) => codes, 686 + Err(e) => { 687 + warn!("Failed to fetch backup codes: {:?}", e); 688 + return false; 689 + } 690 + }; 691 + 692 + for row in backup_codes { 693 + if verify_backup_code(&code, &row.code_hash) { 694 + let _ = sqlx::query!( 695 + "UPDATE backup_codes SET used_at = $1 WHERE id = $2", 696 + Utc::now(), 697 + row.id 698 + ) 699 + .execute(&state.db) 700 + .await; 701 + return true; 702 + } 703 + } 704 + 705 + false 706 + } 707 + 708 + pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool { 709 + let code = code.trim(); 710 + 711 + if is_backup_code_format(code) { 712 + return verify_backup_code_for_user(state, did, code).await; 713 + } 714 + 715 + let totp_row = sqlx::query!( 716 + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", 717 + did 718 + ) 719 + .fetch_optional(&state.db) 720 + .await; 721 + 722 + let totp_row = match totp_row { 723 + Ok(Some(row)) if row.verified => row, 724 + _ => return false, 725 + }; 726 + 727 + let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) 728 + { 729 + Ok(s) => s, 730 + Err(_) => return false, 731 + }; 732 + 733 + if verify_totp_code(&secret, code) { 734 + let _ = sqlx::query!("UPDATE user_totp SET last_used = NOW() WHERE did = $1", did) 735 + .execute(&state.db) 736 + .await; 737 + return true; 738 + } 739 + 740 + false 741 + } 742 + 743 + pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool { 744 + let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did) 745 + .fetch_optional(&state.db) 746 + .await; 747 + 748 + matches!(result, Ok(Some(true))) 749 + }
+2
src/auth/mod.rs
··· 11 pub mod scope_check; 12 pub mod service; 13 pub mod token; 14 pub mod verify; 15 16 pub use extractor::{ 17 AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,
··· 11 pub mod scope_check; 12 pub mod service; 13 pub mod token; 14 + pub mod totp; 15 pub mod verify; 16 + pub mod webauthn; 17 18 pub use extractor::{ 19 AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,
+194
src/auth/totp.rs
···
··· 1 + use base32::Alphabet; 2 + use rand::RngCore; 3 + use subtle::ConstantTimeEq; 4 + use totp_rs::{Algorithm, TOTP}; 5 + 6 + const TOTP_DIGITS: usize = 6; 7 + const TOTP_STEP: u64 = 30; 8 + const TOTP_SECRET_LENGTH: usize = 20; 9 + 10 + pub fn generate_totp_secret() -> Vec<u8> { 11 + let mut secret = vec![0u8; TOTP_SECRET_LENGTH]; 12 + rand::thread_rng().fill_bytes(&mut secret); 13 + secret 14 + } 15 + 16 + pub fn encrypt_totp_secret(secret: &[u8]) -> Result<Vec<u8>, String> { 17 + crate::config::encrypt_key(secret) 18 + } 19 + 20 + pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result<Vec<u8>, String> { 21 + crate::config::decrypt_key(encrypted, Some(version)) 22 + } 23 + 24 + fn create_totp( 25 + secret: Vec<u8>, 26 + issuer: Option<String>, 27 + account_name: String, 28 + ) -> Result<TOTP, String> { 29 + TOTP::new( 30 + Algorithm::SHA1, 31 + TOTP_DIGITS, 32 + 1, 33 + TOTP_STEP, 34 + secret, 35 + issuer, 36 + account_name, 37 + ) 38 + .map_err(|e| format!("Failed to create TOTP: {}", e)) 39 + } 40 + 41 + pub fn verify_totp_code(secret: &[u8], code: &str) -> bool { 42 + let code = code.trim(); 43 + if code.len() != TOTP_DIGITS { 44 + return false; 45 + } 46 + 47 + let Ok(totp) = create_totp(secret.to_vec(), None, String::new()) else { 48 + return false; 49 + }; 50 + 51 + let now = std::time::SystemTime::now() 52 + .duration_since(std::time::UNIX_EPOCH) 53 + .map(|d| d.as_secs()) 54 + .unwrap_or(0); 55 + 56 + for offset in [-1i64, 0, 1] { 57 + let time = (now as i64 + offset * TOTP_STEP as i64) as u64; 58 + let expected = totp.generate(time); 59 + let is_valid: bool = code.as_bytes().ct_eq(expected.as_bytes()).into(); 60 + if is_valid { 61 + return true; 62 + } 63 + } 64 + 65 + false 66 + } 67 + 68 + pub fn generate_totp_uri(secret: &[u8], account_name: &str, issuer: &str) -> String { 69 + let secret_base32 = base32::encode(Alphabet::Rfc4648 { padding: false }, secret); 70 + format!( 71 + "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}", 72 + urlencoding::encode(issuer), 73 + urlencoding::encode(account_name), 74 + secret_base32, 75 + urlencoding::encode(issuer), 76 + TOTP_DIGITS, 77 + TOTP_STEP 78 + ) 79 + } 80 + 81 + pub fn generate_qr_png_base64( 82 + secret: &[u8], 83 + account_name: &str, 84 + issuer: &str, 85 + ) -> Result<String, String> { 86 + use base64::{Engine, engine::general_purpose::STANDARD}; 87 + 88 + let totp = create_totp( 89 + secret.to_vec(), 90 + Some(issuer.to_string()), 91 + account_name.to_string(), 92 + )?; 93 + 94 + let qr_png = totp 95 + .get_qr_png() 96 + .map_err(|e| format!("Failed to generate QR code: {}", e))?; 97 + 98 + Ok(STANDARD.encode(qr_png)) 99 + } 100 + 101 + const BACKUP_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ"; 102 + const BACKUP_CODE_LENGTH: usize = 8; 103 + const BACKUP_CODE_COUNT: usize = 10; 104 + const BACKUP_CODE_BCRYPT_COST: u32 = 10; 105 + 106 + pub fn generate_backup_codes() -> Vec<String> { 107 + let mut codes = Vec::with_capacity(BACKUP_CODE_COUNT); 108 + let mut rng = rand::thread_rng(); 109 + 110 + for _ in 0..BACKUP_CODE_COUNT { 111 + let mut code = String::with_capacity(BACKUP_CODE_LENGTH); 112 + for _ in 0..BACKUP_CODE_LENGTH { 113 + let idx = (rng.next_u32() as usize) % BACKUP_CODE_ALPHABET.len(); 114 + code.push(BACKUP_CODE_ALPHABET[idx] as char); 115 + } 116 + codes.push(code); 117 + } 118 + 119 + codes 120 + } 121 + 122 + pub fn hash_backup_code(code: &str) -> Result<String, String> { 123 + bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| format!("Failed to hash code: {}", e)) 124 + } 125 + 126 + pub fn verify_backup_code(code: &str, hash: &str) -> bool { 127 + bcrypt::verify(code, hash).unwrap_or(false) 128 + } 129 + 130 + pub fn is_backup_code_format(code: &str) -> bool { 131 + let code = code.trim().to_uppercase(); 132 + code.len() == BACKUP_CODE_LENGTH 133 + && code 134 + .chars() 135 + .all(|c| BACKUP_CODE_ALPHABET.contains(&(c as u8))) 136 + } 137 + 138 + #[cfg(test)] 139 + mod tests { 140 + use super::*; 141 + 142 + #[test] 143 + fn test_generate_totp_secret() { 144 + let secret = generate_totp_secret(); 145 + assert_eq!(secret.len(), TOTP_SECRET_LENGTH); 146 + } 147 + 148 + #[test] 149 + fn test_verify_totp_code() { 150 + let secret = generate_totp_secret(); 151 + let totp = create_totp(secret.clone(), None, String::new()).unwrap(); 152 + let code = totp.generate_current().unwrap(); 153 + assert!(verify_totp_code(&secret, &code)); 154 + assert!(!verify_totp_code(&secret, "000000")); 155 + } 156 + 157 + #[test] 158 + fn test_generate_totp_uri() { 159 + let secret = vec![0u8; 20]; 160 + let uri = generate_totp_uri(&secret, "test@example.com", "TestPDS"); 161 + assert!(uri.starts_with("otpauth://totp/")); 162 + assert!(uri.contains("secret=")); 163 + assert!(uri.contains("issuer=TestPDS")); 164 + } 165 + 166 + #[test] 167 + fn test_backup_codes() { 168 + let codes = generate_backup_codes(); 169 + assert_eq!(codes.len(), BACKUP_CODE_COUNT); 170 + for code in &codes { 171 + assert_eq!(code.len(), BACKUP_CODE_LENGTH); 172 + assert!(is_backup_code_format(code)); 173 + } 174 + } 175 + 176 + #[test] 177 + fn test_backup_code_hash_verify() { 178 + let codes = generate_backup_codes(); 179 + let code = &codes[0]; 180 + let hash = hash_backup_code(code).unwrap(); 181 + assert!(verify_backup_code(code, &hash)); 182 + assert!(!verify_backup_code("WRONGCOD", &hash)); 183 + } 184 + 185 + #[test] 186 + fn test_is_backup_code_format() { 187 + assert!(is_backup_code_format("ABCD2345")); 188 + assert!(is_backup_code_format(" abcd2345 ")); 189 + assert!(!is_backup_code_format("ABCD234")); 190 + assert!(!is_backup_code_format("ABCD23456")); 191 + assert!(!is_backup_code_format("ABCD234O")); 192 + assert!(!is_backup_code_format("ABCD2341")); 193 + } 194 + }
+386
src/auth/webauthn.rs
···
··· 1 + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 2 + use chrono::{Duration, Utc}; 3 + use sqlx::{PgPool, Row}; 4 + use uuid::Uuid; 5 + use webauthn_rs::prelude::*; 6 + 7 + pub struct WebAuthnConfig { 8 + webauthn: Webauthn, 9 + } 10 + 11 + impl WebAuthnConfig { 12 + pub fn new(hostname: &str) -> Result<Self, String> { 13 + let rp_id = hostname.to_string(); 14 + let rp_origin = Url::parse(&format!("https://{}", hostname)) 15 + .map_err(|e| format!("Invalid origin URL: {}", e))?; 16 + 17 + let builder = WebauthnBuilder::new(&rp_id, &rp_origin) 18 + .map_err(|e| format!("Failed to create WebAuthn builder: {}", e))? 19 + .rp_name("Tranquil PDS") 20 + .danger_set_user_presence_only_security_keys(true); 21 + 22 + let webauthn = builder 23 + .build() 24 + .map_err(|e| format!("Failed to build WebAuthn: {}", e))?; 25 + 26 + Ok(Self { webauthn }) 27 + } 28 + 29 + pub fn start_registration( 30 + &self, 31 + user_id: &str, 32 + username: &str, 33 + display_name: &str, 34 + exclude_credentials: Vec<CredentialID>, 35 + ) -> Result<(CreationChallengeResponse, SecurityKeyRegistration), String> { 36 + let user_unique_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, user_id.as_bytes()); 37 + 38 + self.webauthn 39 + .start_securitykey_registration( 40 + user_unique_id, 41 + username, 42 + display_name, 43 + if exclude_credentials.is_empty() { 44 + None 45 + } else { 46 + Some(exclude_credentials) 47 + }, 48 + None, 49 + None, 50 + ) 51 + .map_err(|e| format!("Failed to start registration: {}", e)) 52 + } 53 + 54 + pub fn finish_registration( 55 + &self, 56 + reg: &RegisterPublicKeyCredential, 57 + state: &SecurityKeyRegistration, 58 + ) -> Result<SecurityKey, String> { 59 + self.webauthn 60 + .finish_securitykey_registration(reg, state) 61 + .map_err(|e| format!("Failed to finish registration: {}", e)) 62 + } 63 + 64 + pub fn start_authentication( 65 + &self, 66 + credentials: Vec<SecurityKey>, 67 + ) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), String> { 68 + self.webauthn 69 + .start_securitykey_authentication(&credentials) 70 + .map_err(|e| format!("Failed to start authentication: {}", e)) 71 + } 72 + 73 + pub fn finish_authentication( 74 + &self, 75 + auth: &PublicKeyCredential, 76 + state: &SecurityKeyAuthentication, 77 + ) -> Result<AuthenticationResult, String> { 78 + self.webauthn 79 + .finish_securitykey_authentication(auth, state) 80 + .map_err(|e| format!("Failed to finish authentication: {}", e)) 81 + } 82 + } 83 + 84 + pub async fn save_registration_state( 85 + pool: &PgPool, 86 + did: &str, 87 + state: &SecurityKeyRegistration, 88 + ) -> Result<Uuid, sqlx::Error> { 89 + let id = Uuid::new_v4(); 90 + let state_json = serde_json::to_string(state) 91 + .map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize state: {}", e)))?; 92 + let challenge = id.as_bytes().to_vec(); 93 + let expires_at = Utc::now() + Duration::minutes(5); 94 + 95 + sqlx::query!( 96 + r#" 97 + INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at) 98 + VALUES ($1, $2, $3, 'registration', $4, $5) 99 + "#, 100 + id, 101 + did, 102 + challenge, 103 + state_json, 104 + expires_at, 105 + ) 106 + .execute(pool) 107 + .await?; 108 + 109 + Ok(id) 110 + } 111 + 112 + pub async fn load_registration_state( 113 + pool: &PgPool, 114 + did: &str, 115 + ) -> Result<Option<SecurityKeyRegistration>, sqlx::Error> { 116 + let row = sqlx::query!( 117 + r#" 118 + SELECT state_json FROM webauthn_challenges 119 + WHERE did = $1 AND challenge_type = 'registration' AND expires_at > NOW() 120 + ORDER BY created_at DESC 121 + LIMIT 1 122 + "#, 123 + did, 124 + ) 125 + .fetch_optional(pool) 126 + .await?; 127 + 128 + match row { 129 + Some(r) => { 130 + let state: SecurityKeyRegistration = 131 + serde_json::from_str(&r.state_json).map_err(|e| { 132 + sqlx::Error::Protocol(format!("Failed to deserialize state: {}", e)) 133 + })?; 134 + Ok(Some(state)) 135 + } 136 + None => Ok(None), 137 + } 138 + } 139 + 140 + pub async fn delete_registration_state(pool: &PgPool, did: &str) -> Result<(), sqlx::Error> { 141 + sqlx::query!( 142 + "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'registration'", 143 + did, 144 + ) 145 + .execute(pool) 146 + .await?; 147 + Ok(()) 148 + } 149 + 150 + pub async fn save_authentication_state( 151 + pool: &PgPool, 152 + did: &str, 153 + state: &SecurityKeyAuthentication, 154 + ) -> Result<Uuid, sqlx::Error> { 155 + let id = Uuid::new_v4(); 156 + let state_json = serde_json::to_string(state) 157 + .map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize state: {}", e)))?; 158 + let challenge = id.as_bytes().to_vec(); 159 + let expires_at = Utc::now() + Duration::minutes(5); 160 + 161 + sqlx::query!( 162 + r#" 163 + INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at) 164 + VALUES ($1, $2, $3, 'authentication', $4, $5) 165 + "#, 166 + id, 167 + did, 168 + challenge, 169 + state_json, 170 + expires_at, 171 + ) 172 + .execute(pool) 173 + .await?; 174 + 175 + Ok(id) 176 + } 177 + 178 + pub async fn load_authentication_state( 179 + pool: &PgPool, 180 + did: &str, 181 + ) -> Result<Option<SecurityKeyAuthentication>, sqlx::Error> { 182 + let row = sqlx::query!( 183 + r#" 184 + SELECT state_json FROM webauthn_challenges 185 + WHERE did = $1 AND challenge_type = 'authentication' AND expires_at > NOW() 186 + ORDER BY created_at DESC 187 + LIMIT 1 188 + "#, 189 + did, 190 + ) 191 + .fetch_optional(pool) 192 + .await?; 193 + 194 + match row { 195 + Some(r) => { 196 + let state: SecurityKeyAuthentication = 197 + serde_json::from_str(&r.state_json).map_err(|e| { 198 + sqlx::Error::Protocol(format!("Failed to deserialize state: {}", e)) 199 + })?; 200 + Ok(Some(state)) 201 + } 202 + None => Ok(None), 203 + } 204 + } 205 + 206 + pub async fn delete_authentication_state(pool: &PgPool, did: &str) -> Result<(), sqlx::Error> { 207 + sqlx::query!( 208 + "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'authentication'", 209 + did, 210 + ) 211 + .execute(pool) 212 + .await?; 213 + Ok(()) 214 + } 215 + 216 + pub async fn cleanup_expired_challenges(pool: &PgPool) -> Result<u64, sqlx::Error> { 217 + let result = sqlx::query!("DELETE FROM webauthn_challenges WHERE expires_at < NOW()") 218 + .execute(pool) 219 + .await?; 220 + Ok(result.rows_affected()) 221 + } 222 + 223 + #[derive(Debug, Clone)] 224 + pub struct StoredPasskey { 225 + pub id: Uuid, 226 + pub did: String, 227 + pub credential_id: Vec<u8>, 228 + pub public_key: Vec<u8>, 229 + pub sign_count: i32, 230 + pub created_at: chrono::DateTime<Utc>, 231 + pub last_used: Option<chrono::DateTime<Utc>>, 232 + pub friendly_name: Option<String>, 233 + pub aaguid: Option<Vec<u8>>, 234 + pub transports: Option<Vec<String>>, 235 + } 236 + 237 + impl StoredPasskey { 238 + pub fn to_security_key(&self) -> Result<SecurityKey, String> { 239 + serde_json::from_slice(&self.public_key) 240 + .map_err(|e| format!("Failed to deserialize security key: {}", e)) 241 + } 242 + 243 + pub fn credential_id_base64(&self) -> String { 244 + URL_SAFE_NO_PAD.encode(&self.credential_id) 245 + } 246 + } 247 + 248 + pub async fn save_passkey( 249 + pool: &PgPool, 250 + did: &str, 251 + security_key: &SecurityKey, 252 + friendly_name: Option<&str>, 253 + ) -> Result<Uuid, sqlx::Error> { 254 + let id = Uuid::new_v4(); 255 + let credential_id = security_key.cred_id().to_vec(); 256 + let public_key = serde_json::to_vec(security_key) 257 + .map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize security key: {}", e)))?; 258 + let aaguid: Option<Vec<u8>> = None; 259 + 260 + sqlx::query!( 261 + r#" 262 + INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name, aaguid) 263 + VALUES ($1, $2, $3, $4, 0, $5, $6) 264 + "#, 265 + id, 266 + did, 267 + credential_id, 268 + public_key, 269 + friendly_name, 270 + aaguid, 271 + ) 272 + .execute(pool) 273 + .await?; 274 + 275 + Ok(id) 276 + } 277 + 278 + pub async fn get_passkeys_for_user( 279 + pool: &PgPool, 280 + did: &str, 281 + ) -> Result<Vec<StoredPasskey>, sqlx::Error> { 282 + let rows = sqlx::query!( 283 + r#" 284 + SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports 285 + FROM passkeys 286 + WHERE did = $1 287 + ORDER BY created_at DESC 288 + "#, 289 + did, 290 + ) 291 + .fetch_all(pool) 292 + .await?; 293 + 294 + Ok(rows 295 + .into_iter() 296 + .map(|r| StoredPasskey { 297 + id: r.id, 298 + did: r.did, 299 + credential_id: r.credential_id, 300 + public_key: r.public_key, 301 + sign_count: r.sign_count, 302 + created_at: r.created_at, 303 + last_used: r.last_used, 304 + friendly_name: r.friendly_name, 305 + aaguid: r.aaguid, 306 + transports: r.transports, 307 + }) 308 + .collect()) 309 + } 310 + 311 + pub async fn get_passkey_by_credential_id( 312 + pool: &PgPool, 313 + credential_id: &[u8], 314 + ) -> Result<Option<StoredPasskey>, sqlx::Error> { 315 + let row = sqlx::query!( 316 + r#" 317 + SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports 318 + FROM passkeys 319 + WHERE credential_id = $1 320 + "#, 321 + credential_id, 322 + ) 323 + .fetch_optional(pool) 324 + .await?; 325 + 326 + Ok(row.map(|r| StoredPasskey { 327 + id: r.id, 328 + did: r.did, 329 + credential_id: r.credential_id, 330 + public_key: r.public_key, 331 + sign_count: r.sign_count, 332 + created_at: r.created_at, 333 + last_used: r.last_used, 334 + friendly_name: r.friendly_name, 335 + aaguid: r.aaguid, 336 + transports: r.transports, 337 + })) 338 + } 339 + 340 + pub async fn update_passkey_counter( 341 + pool: &PgPool, 342 + credential_id: &[u8], 343 + new_counter: u32, 344 + ) -> Result<(), sqlx::Error> { 345 + sqlx::query!( 346 + "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", 347 + new_counter as i32, 348 + credential_id, 349 + ) 350 + .execute(pool) 351 + .await?; 352 + Ok(()) 353 + } 354 + 355 + pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result<bool, sqlx::Error> { 356 + let result = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND did = $2") 357 + .bind(id) 358 + .bind(did) 359 + .execute(pool) 360 + .await?; 361 + Ok(result.rows_affected() > 0) 362 + } 363 + 364 + pub async fn update_passkey_name( 365 + pool: &PgPool, 366 + id: Uuid, 367 + did: &str, 368 + name: &str, 369 + ) -> Result<bool, sqlx::Error> { 370 + let result = sqlx::query("UPDATE passkeys SET friendly_name = $1 WHERE id = $2 AND did = $3") 371 + .bind(name) 372 + .bind(id) 373 + .bind(did) 374 + .execute(pool) 375 + .await?; 376 + Ok(result.rows_affected() > 0) 377 + } 378 + 379 + pub async fn has_passkeys(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> { 380 + let row = sqlx::query("SELECT COUNT(*) as count FROM passkeys WHERE did = $1") 381 + .bind(did) 382 + .fetch_one(pool) 383 + .await?; 384 + let count: i64 = row.get("count"); 385 + Ok(count > 0) 386 + }
+56
src/lib.rs
··· 279 get(api::server::get_account_invite_codes), 280 ) 281 .route( 282 "/xrpc/com.atproto.admin.getInviteCodes", 283 get(api::admin::get_invite_codes), 284 ) ··· 358 .route( 359 "/oauth/authorize/2fa", 360 post(oauth::endpoints::authorize_2fa_post), 361 ) 362 .route( 363 "/oauth/authorize/deny",
··· 279 get(api::server::get_account_invite_codes), 280 ) 281 .route( 282 + "/xrpc/com.atproto.server.createTotpSecret", 283 + post(api::server::create_totp_secret), 284 + ) 285 + .route( 286 + "/xrpc/com.atproto.server.enableTotp", 287 + post(api::server::enable_totp), 288 + ) 289 + .route( 290 + "/xrpc/com.atproto.server.disableTotp", 291 + post(api::server::disable_totp), 292 + ) 293 + .route( 294 + "/xrpc/com.atproto.server.getTotpStatus", 295 + get(api::server::get_totp_status), 296 + ) 297 + .route( 298 + "/xrpc/com.atproto.server.regenerateBackupCodes", 299 + post(api::server::regenerate_backup_codes), 300 + ) 301 + .route( 302 + "/xrpc/com.atproto.server.startPasskeyRegistration", 303 + post(api::server::start_passkey_registration), 304 + ) 305 + .route( 306 + "/xrpc/com.atproto.server.finishPasskeyRegistration", 307 + post(api::server::finish_passkey_registration), 308 + ) 309 + .route( 310 + "/xrpc/com.atproto.server.listPasskeys", 311 + get(api::server::list_passkeys), 312 + ) 313 + .route( 314 + "/xrpc/com.atproto.server.deletePasskey", 315 + post(api::server::delete_passkey), 316 + ) 317 + .route( 318 + "/xrpc/com.atproto.server.updatePasskey", 319 + post(api::server::update_passkey), 320 + ) 321 + .route( 322 "/xrpc/com.atproto.admin.getInviteCodes", 323 get(api::admin::get_invite_codes), 324 ) ··· 398 .route( 399 "/oauth/authorize/2fa", 400 post(oauth::endpoints::authorize_2fa_post), 401 + ) 402 + .route( 403 + "/oauth/passkey/check", 404 + get(oauth::endpoints::check_user_has_passkeys), 405 + ) 406 + .route( 407 + "/oauth/security-status", 408 + get(oauth::endpoints::check_user_security_status), 409 + ) 410 + .route( 411 + "/oauth/passkey/start", 412 + post(oauth::endpoints::passkey_start), 413 + ) 414 + .route( 415 + "/oauth/passkey/finish", 416 + post(oauth::endpoints::passkey_finish), 417 ) 418 .route( 419 "/oauth/authorize/deny",
+759 -35
src/oauth/endpoints/authorize.rs
··· 486 if !password_valid { 487 return show_login_error("Invalid handle/email or password.", json_response); 488 } 489 if user.two_factor_enabled { 490 let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; 491 match db::create_2fa_challenge(&state.db, &user.did, &form.request_uri).await { ··· 744 "access_denied", 745 "Please verify your account before logging in.", 746 ); 747 } 748 if user.two_factor_enabled { 749 let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; ··· 1323 "Too many attempts. Please try again later.", 1324 ); 1325 } 1326 - let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await { 1327 - Ok(Some(c)) => c, 1328 Ok(None) => { 1329 return json_error( 1330 StatusCode::BAD_REQUEST, 1331 "invalid_request", 1332 - "No 2FA challenge found. Please start over.", 1333 ); 1334 } 1335 Err(_) => { 1336 return json_error( 1337 StatusCode::INTERNAL_SERVER_ERROR, 1338 "server_error", 1339 - "An error occurred. Please try again.", 1340 ); 1341 } 1342 }; 1343 - if challenge.expires_at < Utc::now() { 1344 - let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; 1345 return json_error( 1346 StatusCode::BAD_REQUEST, 1347 "invalid_request", 1348 - "2FA code has expired. Please start over.", 1349 ); 1350 } 1351 - if challenge.attempts >= MAX_2FA_ATTEMPTS { 1352 let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; 1353 return json_error( 1354 - StatusCode::FORBIDDEN, 1355 - "access_denied", 1356 - "Too many failed attempts. Please start over.", 1357 ); 1358 } 1359 - let code_valid: bool = form 1360 - .code 1361 - .trim() 1362 - .as_bytes() 1363 - .ct_eq(challenge.code.as_bytes()) 1364 - .into(); 1365 - if !code_valid { 1366 - let _ = db::increment_2fa_attempts(&state.db, challenge.id).await; 1367 return json_error( 1368 StatusCode::FORBIDDEN, 1369 "invalid_code", 1370 "Invalid verification code. Please try again.", 1371 ); 1372 } 1373 - let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; 1374 let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { 1375 - Ok(Some(d)) => d, 1376 Ok(None) => { 1377 - return json_error( 1378 StatusCode::BAD_REQUEST, 1379 - "invalid_request", 1380 - "Authorization request not found.", 1381 - ); 1382 } 1383 Err(_) => { 1384 - return json_error( 1385 StatusCode::INTERNAL_SERVER_ERROR, 1386 - "server_error", 1387 - "An error occurred.", 1388 - ); 1389 } 1390 }; 1391 - let code = Code::generate(); 1392 let device_id = extract_device_cookie(&headers); 1393 if db::update_authorization_request( 1394 &state.db, 1395 &form.request_uri, 1396 - &challenge.did, 1397 device_id.as_deref(), 1398 &code.0, 1399 ) 1400 .await 1401 .is_err() 1402 { 1403 - return json_error( 1404 StatusCode::INTERNAL_SERVER_ERROR, 1405 - "server_error", 1406 - "An error occurred. Please try again.", 1407 - ); 1408 } 1409 let redirect_url = build_success_redirect( 1410 &request_data.parameters.redirect_uri, 1411 &code.0, 1412 request_data.parameters.state.as_deref(), 1413 request_data.parameters.response_mode.as_deref(), 1414 ); 1415 Json(serde_json::json!({ 1416 "redirect_uri": redirect_url 1417 }))
··· 486 if !password_valid { 487 return show_login_error("Invalid handle/email or password.", json_response); 488 } 489 + let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await; 490 + if has_totp { 491 + if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None) 492 + .await 493 + .is_err() 494 + { 495 + return show_login_error("An error occurred. Please try again.", json_response); 496 + } 497 + if json_response { 498 + return Json(serde_json::json!({ 499 + "needs_totp": true 500 + })) 501 + .into_response(); 502 + } 503 + return redirect_see_other(&format!( 504 + "/#/oauth/totp?request_uri={}", 505 + url_encode(&form.request_uri) 506 + )); 507 + } 508 if user.two_factor_enabled { 509 let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; 510 match db::create_2fa_challenge(&state.db, &user.did, &form.request_uri).await { ··· 763 "access_denied", 764 "Please verify your account before logging in.", 765 ); 766 + } 767 + let has_totp = crate::api::server::has_totp_enabled(&state, &form.did).await; 768 + if has_totp { 769 + if db::set_authorization_did(&state.db, &form.request_uri, &form.did, Some(&device_id)) 770 + .await 771 + .is_err() 772 + { 773 + return json_error( 774 + StatusCode::INTERNAL_SERVER_ERROR, 775 + "server_error", 776 + "An error occurred. Please try again.", 777 + ); 778 + } 779 + return Json(serde_json::json!({ 780 + "needs_totp": true 781 + })) 782 + .into_response(); 783 } 784 if user.two_factor_enabled { 785 let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; ··· 1359 "Too many attempts. Please try again later.", 1360 ); 1361 } 1362 + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { 1363 + Ok(Some(d)) => d, 1364 Ok(None) => { 1365 return json_error( 1366 StatusCode::BAD_REQUEST, 1367 "invalid_request", 1368 + "Authorization request not found.", 1369 ); 1370 } 1371 Err(_) => { 1372 return json_error( 1373 StatusCode::INTERNAL_SERVER_ERROR, 1374 "server_error", 1375 + "An error occurred.", 1376 ); 1377 } 1378 }; 1379 + if request_data.expires_at < Utc::now() { 1380 + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; 1381 return json_error( 1382 StatusCode::BAD_REQUEST, 1383 "invalid_request", 1384 + "Authorization request has expired.", 1385 ); 1386 } 1387 + let challenge = db::get_2fa_challenge(&state.db, &form.request_uri) 1388 + .await 1389 + .ok() 1390 + .flatten(); 1391 + if let Some(challenge) = challenge { 1392 + if challenge.expires_at < Utc::now() { 1393 + let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; 1394 + return json_error( 1395 + StatusCode::BAD_REQUEST, 1396 + "invalid_request", 1397 + "2FA code has expired. Please start over.", 1398 + ); 1399 + } 1400 + if challenge.attempts >= MAX_2FA_ATTEMPTS { 1401 + let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; 1402 + return json_error( 1403 + StatusCode::FORBIDDEN, 1404 + "access_denied", 1405 + "Too many failed attempts. Please start over.", 1406 + ); 1407 + } 1408 + let code_valid: bool = form 1409 + .code 1410 + .trim() 1411 + .as_bytes() 1412 + .ct_eq(challenge.code.as_bytes()) 1413 + .into(); 1414 + if !code_valid { 1415 + let _ = db::increment_2fa_attempts(&state.db, challenge.id).await; 1416 + return json_error( 1417 + StatusCode::FORBIDDEN, 1418 + "invalid_code", 1419 + "Invalid verification code. Please try again.", 1420 + ); 1421 + } 1422 let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; 1423 + let code = Code::generate(); 1424 + let device_id = extract_device_cookie(&headers); 1425 + if db::update_authorization_request( 1426 + &state.db, 1427 + &form.request_uri, 1428 + &challenge.did, 1429 + device_id.as_deref(), 1430 + &code.0, 1431 + ) 1432 + .await 1433 + .is_err() 1434 + { 1435 + return json_error( 1436 + StatusCode::INTERNAL_SERVER_ERROR, 1437 + "server_error", 1438 + "An error occurred. Please try again.", 1439 + ); 1440 + } 1441 + let redirect_url = build_success_redirect( 1442 + &request_data.parameters.redirect_uri, 1443 + &code.0, 1444 + request_data.parameters.state.as_deref(), 1445 + request_data.parameters.response_mode.as_deref(), 1446 + ); 1447 + return Json(serde_json::json!({ 1448 + "redirect_uri": redirect_url 1449 + })) 1450 + .into_response(); 1451 + } 1452 + let did = match &request_data.did { 1453 + Some(d) => d.clone(), 1454 + None => { 1455 + return json_error( 1456 + StatusCode::BAD_REQUEST, 1457 + "invalid_request", 1458 + "No 2FA challenge found. Please start over.", 1459 + ); 1460 + } 1461 + }; 1462 + if !crate::api::server::has_totp_enabled(&state, &did).await { 1463 return json_error( 1464 + StatusCode::BAD_REQUEST, 1465 + "invalid_request", 1466 + "No 2FA challenge found. Please start over.", 1467 ); 1468 } 1469 + let totp_valid = 1470 + crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await; 1471 + if !totp_valid { 1472 return json_error( 1473 StatusCode::FORBIDDEN, 1474 "invalid_code", 1475 "Invalid verification code. Please try again.", 1476 ); 1477 } 1478 + let requested_scope_str = request_data 1479 + .parameters 1480 + .scope 1481 + .as_deref() 1482 + .unwrap_or("atproto"); 1483 + let requested_scopes: Vec<String> = requested_scope_str 1484 + .split_whitespace() 1485 + .map(|s| s.to_string()) 1486 + .collect(); 1487 + let needs_consent = db::should_show_consent( 1488 + &state.db, 1489 + &did, 1490 + &request_data.parameters.client_id, 1491 + &requested_scopes, 1492 + ) 1493 + .await 1494 + .unwrap_or(true); 1495 + if needs_consent { 1496 + let consent_url = format!( 1497 + "/#/oauth/consent?request_uri={}", 1498 + url_encode(&form.request_uri) 1499 + ); 1500 + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); 1501 + } 1502 + let code = Code::generate(); 1503 + let device_id = extract_device_cookie(&headers); 1504 + if db::update_authorization_request( 1505 + &state.db, 1506 + &form.request_uri, 1507 + &did, 1508 + device_id.as_deref(), 1509 + &code.0, 1510 + ) 1511 + .await 1512 + .is_err() 1513 + { 1514 + return json_error( 1515 + StatusCode::INTERNAL_SERVER_ERROR, 1516 + "server_error", 1517 + "An error occurred. Please try again.", 1518 + ); 1519 + } 1520 + let redirect_url = build_success_redirect( 1521 + &request_data.parameters.redirect_uri, 1522 + &code.0, 1523 + request_data.parameters.state.as_deref(), 1524 + request_data.parameters.response_mode.as_deref(), 1525 + ); 1526 + Json(serde_json::json!({ 1527 + "redirect_uri": redirect_url 1528 + })) 1529 + .into_response() 1530 + } 1531 + 1532 + #[derive(Debug, Deserialize)] 1533 + #[serde(rename_all = "camelCase")] 1534 + pub struct CheckPasskeysQuery { 1535 + pub identifier: String, 1536 + } 1537 + 1538 + #[derive(Debug, Serialize)] 1539 + #[serde(rename_all = "camelCase")] 1540 + pub struct CheckPasskeysResponse { 1541 + pub has_passkeys: bool, 1542 + } 1543 + 1544 + pub async fn check_user_has_passkeys( 1545 + State(state): State<AppState>, 1546 + Query(query): Query<CheckPasskeysQuery>, 1547 + ) -> Response { 1548 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1549 + let normalized_identifier = query.identifier.trim(); 1550 + let normalized_identifier = normalized_identifier 1551 + .strip_prefix('@') 1552 + .unwrap_or(normalized_identifier); 1553 + let normalized_identifier = if let Some(bare_handle) = 1554 + normalized_identifier.strip_suffix(&format!(".{}", pds_hostname)) 1555 + { 1556 + bare_handle.to_string() 1557 + } else { 1558 + normalized_identifier.to_string() 1559 + }; 1560 + 1561 + let user = sqlx::query!( 1562 + "SELECT did FROM users WHERE handle = $1 OR email = $1", 1563 + normalized_identifier 1564 + ) 1565 + .fetch_optional(&state.db) 1566 + .await; 1567 + 1568 + let has_passkeys = match user { 1569 + Ok(Some(u)) => crate::api::server::has_passkeys_for_user(&state, &u.did).await, 1570 + _ => false, 1571 + }; 1572 + 1573 + Json(CheckPasskeysResponse { has_passkeys }).into_response() 1574 + } 1575 + 1576 + #[derive(Debug, Serialize)] 1577 + #[serde(rename_all = "camelCase")] 1578 + pub struct SecurityStatusResponse { 1579 + pub has_passkeys: bool, 1580 + pub has_totp: bool, 1581 + } 1582 + 1583 + pub async fn check_user_security_status( 1584 + State(state): State<AppState>, 1585 + Query(query): Query<CheckPasskeysQuery>, 1586 + ) -> Response { 1587 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1588 + let normalized_identifier = query.identifier.trim(); 1589 + let normalized_identifier = normalized_identifier 1590 + .strip_prefix('@') 1591 + .unwrap_or(normalized_identifier); 1592 + let normalized_identifier = if let Some(bare_handle) = 1593 + normalized_identifier.strip_suffix(&format!(".{}", pds_hostname)) 1594 + { 1595 + bare_handle.to_string() 1596 + } else { 1597 + normalized_identifier.to_string() 1598 + }; 1599 + 1600 + let user = sqlx::query!( 1601 + "SELECT did FROM users WHERE handle = $1 OR email = $1", 1602 + normalized_identifier 1603 + ) 1604 + .fetch_optional(&state.db) 1605 + .await; 1606 + 1607 + let (has_passkeys, has_totp) = match user { 1608 + Ok(Some(u)) => { 1609 + let passkeys = crate::api::server::has_passkeys_for_user(&state, &u.did).await; 1610 + let totp = crate::api::server::has_totp_enabled(&state, &u.did).await; 1611 + (passkeys, totp) 1612 + } 1613 + _ => (false, false), 1614 + }; 1615 + 1616 + Json(SecurityStatusResponse { 1617 + has_passkeys, 1618 + has_totp, 1619 + }) 1620 + .into_response() 1621 + } 1622 + 1623 + #[derive(Debug, Deserialize)] 1624 + pub struct PasskeyStartInput { 1625 + pub request_uri: String, 1626 + pub identifier: String, 1627 + } 1628 + 1629 + #[derive(Debug, Serialize)] 1630 + #[serde(rename_all = "camelCase")] 1631 + pub struct PasskeyStartResponse { 1632 + pub options: serde_json::Value, 1633 + } 1634 + 1635 + pub async fn passkey_start( 1636 + State(state): State<AppState>, 1637 + headers: HeaderMap, 1638 + Json(form): Json<PasskeyStartInput>, 1639 + ) -> Response { 1640 + let client_ip = extract_client_ip(&headers); 1641 + 1642 + if !state 1643 + .check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip) 1644 + .await 1645 + { 1646 + tracing::warn!(ip = %client_ip, "OAuth passkey rate limit exceeded"); 1647 + return ( 1648 + StatusCode::TOO_MANY_REQUESTS, 1649 + Json(serde_json::json!({ 1650 + "error": "RateLimitExceeded", 1651 + "error_description": "Too many login attempts. Please try again later." 1652 + })), 1653 + ) 1654 + .into_response(); 1655 + } 1656 + 1657 let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { 1658 + Ok(Some(data)) => data, 1659 Ok(None) => { 1660 + return ( 1661 StatusCode::BAD_REQUEST, 1662 + Json(serde_json::json!({ 1663 + "error": "invalid_request", 1664 + "error_description": "Invalid or expired request_uri." 1665 + })), 1666 + ) 1667 + .into_response(); 1668 } 1669 Err(_) => { 1670 + return ( 1671 StatusCode::INTERNAL_SERVER_ERROR, 1672 + Json(serde_json::json!({ 1673 + "error": "server_error", 1674 + "error_description": "An error occurred." 1675 + })), 1676 + ) 1677 + .into_response(); 1678 } 1679 }; 1680 + 1681 + if request_data.expires_at < Utc::now() { 1682 + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; 1683 + return ( 1684 + StatusCode::BAD_REQUEST, 1685 + Json(serde_json::json!({ 1686 + "error": "invalid_request", 1687 + "error_description": "Authorization request has expired." 1688 + })), 1689 + ) 1690 + .into_response(); 1691 + } 1692 + 1693 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1694 + let normalized_username = form.identifier.trim(); 1695 + let normalized_username = normalized_username 1696 + .strip_prefix('@') 1697 + .unwrap_or(normalized_username); 1698 + let normalized_username = if let Some(bare_handle) = 1699 + normalized_username.strip_suffix(&format!(".{}", pds_hostname)) 1700 + { 1701 + bare_handle.to_string() 1702 + } else { 1703 + normalized_username.to_string() 1704 + }; 1705 + 1706 + let user = match sqlx::query!( 1707 + r#" 1708 + SELECT did, deactivated_at, takedown_ref, 1709 + email_verified, discord_verified, telegram_verified, signal_verified 1710 + FROM users 1711 + WHERE handle = $1 OR email = $1 1712 + "#, 1713 + normalized_username 1714 + ) 1715 + .fetch_optional(&state.db) 1716 + .await 1717 + { 1718 + Ok(Some(u)) => u, 1719 + Ok(None) => { 1720 + return ( 1721 + StatusCode::FORBIDDEN, 1722 + Json(serde_json::json!({ 1723 + "error": "access_denied", 1724 + "error_description": "User not found or has no passkeys." 1725 + })), 1726 + ) 1727 + .into_response(); 1728 + } 1729 + Err(_) => { 1730 + return ( 1731 + StatusCode::INTERNAL_SERVER_ERROR, 1732 + Json(serde_json::json!({ 1733 + "error": "server_error", 1734 + "error_description": "An error occurred." 1735 + })), 1736 + ) 1737 + .into_response(); 1738 + } 1739 + }; 1740 + 1741 + if user.deactivated_at.is_some() { 1742 + return ( 1743 + StatusCode::FORBIDDEN, 1744 + Json(serde_json::json!({ 1745 + "error": "access_denied", 1746 + "error_description": "This account has been deactivated." 1747 + })), 1748 + ) 1749 + .into_response(); 1750 + } 1751 + 1752 + if user.takedown_ref.is_some() { 1753 + return ( 1754 + StatusCode::FORBIDDEN, 1755 + Json(serde_json::json!({ 1756 + "error": "access_denied", 1757 + "error_description": "This account has been taken down." 1758 + })), 1759 + ) 1760 + .into_response(); 1761 + } 1762 + 1763 + let is_verified = user.email_verified 1764 + || user.discord_verified 1765 + || user.telegram_verified 1766 + || user.signal_verified; 1767 + 1768 + if !is_verified { 1769 + return ( 1770 + StatusCode::FORBIDDEN, 1771 + Json(serde_json::json!({ 1772 + "error": "access_denied", 1773 + "error_description": "Please verify your account before logging in." 1774 + })), 1775 + ) 1776 + .into_response(); 1777 + } 1778 + 1779 + let stored_passkeys = 1780 + match crate::auth::webauthn::get_passkeys_for_user(&state.db, &user.did).await { 1781 + Ok(pks) => pks, 1782 + Err(e) => { 1783 + tracing::error!(error = %e, "Failed to get passkeys"); 1784 + return ( 1785 + StatusCode::INTERNAL_SERVER_ERROR, 1786 + Json(serde_json::json!({ 1787 + "error": "server_error", 1788 + "error_description": "An error occurred." 1789 + })), 1790 + ) 1791 + .into_response(); 1792 + } 1793 + }; 1794 + 1795 + if stored_passkeys.is_empty() { 1796 + return ( 1797 + StatusCode::FORBIDDEN, 1798 + Json(serde_json::json!({ 1799 + "error": "access_denied", 1800 + "error_description": "User not found or has no passkeys." 1801 + })), 1802 + ) 1803 + .into_response(); 1804 + } 1805 + 1806 + let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys 1807 + .iter() 1808 + .filter_map(|sp| sp.to_security_key().ok()) 1809 + .collect(); 1810 + 1811 + if passkeys.is_empty() { 1812 + return ( 1813 + StatusCode::INTERNAL_SERVER_ERROR, 1814 + Json(serde_json::json!({ 1815 + "error": "server_error", 1816 + "error_description": "Failed to load passkeys." 1817 + })), 1818 + ) 1819 + .into_response(); 1820 + } 1821 + 1822 + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 1823 + Ok(w) => w, 1824 + Err(e) => { 1825 + tracing::error!(error = %e, "Failed to create WebAuthn config"); 1826 + return ( 1827 + StatusCode::INTERNAL_SERVER_ERROR, 1828 + Json(serde_json::json!({ 1829 + "error": "server_error", 1830 + "error_description": "WebAuthn configuration failed." 1831 + })), 1832 + ) 1833 + .into_response(); 1834 + } 1835 + }; 1836 + 1837 + let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { 1838 + Ok(result) => result, 1839 + Err(e) => { 1840 + tracing::error!(error = %e, "Failed to start passkey authentication"); 1841 + return ( 1842 + StatusCode::INTERNAL_SERVER_ERROR, 1843 + Json(serde_json::json!({ 1844 + "error": "server_error", 1845 + "error_description": "Failed to start authentication." 1846 + })), 1847 + ) 1848 + .into_response(); 1849 + } 1850 + }; 1851 + 1852 + if let Err(e) = 1853 + crate::auth::webauthn::save_authentication_state(&state.db, &user.did, &auth_state).await 1854 + { 1855 + tracing::error!(error = %e, "Failed to save authentication state"); 1856 + return ( 1857 + StatusCode::INTERNAL_SERVER_ERROR, 1858 + Json(serde_json::json!({ 1859 + "error": "server_error", 1860 + "error_description": "An error occurred." 1861 + })), 1862 + ) 1863 + .into_response(); 1864 + } 1865 + 1866 + if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None) 1867 + .await 1868 + .is_err() 1869 + { 1870 + return ( 1871 + StatusCode::INTERNAL_SERVER_ERROR, 1872 + Json(serde_json::json!({ 1873 + "error": "server_error", 1874 + "error_description": "An error occurred." 1875 + })), 1876 + ) 1877 + .into_response(); 1878 + } 1879 + 1880 + let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); 1881 + 1882 + Json(PasskeyStartResponse { options }).into_response() 1883 + } 1884 + 1885 + #[derive(Debug, Deserialize)] 1886 + pub struct PasskeyFinishInput { 1887 + pub request_uri: String, 1888 + pub credential: serde_json::Value, 1889 + } 1890 + 1891 + pub async fn passkey_finish( 1892 + State(state): State<AppState>, 1893 + headers: HeaderMap, 1894 + Json(form): Json<PasskeyFinishInput>, 1895 + ) -> Response { 1896 + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { 1897 + Ok(Some(data)) => data, 1898 + Ok(None) => { 1899 + return ( 1900 + StatusCode::BAD_REQUEST, 1901 + Json(serde_json::json!({ 1902 + "error": "invalid_request", 1903 + "error_description": "Invalid or expired request_uri." 1904 + })), 1905 + ) 1906 + .into_response(); 1907 + } 1908 + Err(_) => { 1909 + return ( 1910 + StatusCode::INTERNAL_SERVER_ERROR, 1911 + Json(serde_json::json!({ 1912 + "error": "server_error", 1913 + "error_description": "An error occurred." 1914 + })), 1915 + ) 1916 + .into_response(); 1917 + } 1918 + }; 1919 + 1920 + if request_data.expires_at < Utc::now() { 1921 + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; 1922 + return ( 1923 + StatusCode::BAD_REQUEST, 1924 + Json(serde_json::json!({ 1925 + "error": "invalid_request", 1926 + "error_description": "Authorization request has expired." 1927 + })), 1928 + ) 1929 + .into_response(); 1930 + } 1931 + 1932 + let did = match request_data.did { 1933 + Some(d) => d, 1934 + None => { 1935 + return ( 1936 + StatusCode::BAD_REQUEST, 1937 + Json(serde_json::json!({ 1938 + "error": "invalid_request", 1939 + "error_description": "No passkey authentication in progress." 1940 + })), 1941 + ) 1942 + .into_response(); 1943 + } 1944 + }; 1945 + 1946 + let auth_state = match crate::auth::webauthn::load_authentication_state(&state.db, &did).await { 1947 + Ok(Some(s)) => s, 1948 + Ok(None) => { 1949 + return ( 1950 + StatusCode::BAD_REQUEST, 1951 + Json(serde_json::json!({ 1952 + "error": "invalid_request", 1953 + "error_description": "No passkey authentication in progress or challenge expired." 1954 + })), 1955 + ) 1956 + .into_response(); 1957 + } 1958 + Err(e) => { 1959 + tracing::error!(error = %e, "Failed to load authentication state"); 1960 + return ( 1961 + StatusCode::INTERNAL_SERVER_ERROR, 1962 + Json(serde_json::json!({ 1963 + "error": "server_error", 1964 + "error_description": "An error occurred." 1965 + })), 1966 + ) 1967 + .into_response(); 1968 + } 1969 + }; 1970 + 1971 + let credential: webauthn_rs::prelude::PublicKeyCredential = 1972 + match serde_json::from_value(form.credential) { 1973 + Ok(c) => c, 1974 + Err(e) => { 1975 + tracing::warn!(error = %e, "Failed to parse credential"); 1976 + return ( 1977 + StatusCode::BAD_REQUEST, 1978 + Json(serde_json::json!({ 1979 + "error": "invalid_request", 1980 + "error_description": "Failed to parse credential response." 1981 + })), 1982 + ) 1983 + .into_response(); 1984 + } 1985 + }; 1986 + 1987 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1988 + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { 1989 + Ok(w) => w, 1990 + Err(e) => { 1991 + tracing::error!(error = %e, "Failed to create WebAuthn config"); 1992 + return ( 1993 + StatusCode::INTERNAL_SERVER_ERROR, 1994 + Json(serde_json::json!({ 1995 + "error": "server_error", 1996 + "error_description": "WebAuthn configuration failed." 1997 + })), 1998 + ) 1999 + .into_response(); 2000 + } 2001 + }; 2002 + 2003 + let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { 2004 + Ok(r) => r, 2005 + Err(e) => { 2006 + tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication"); 2007 + return ( 2008 + StatusCode::FORBIDDEN, 2009 + Json(serde_json::json!({ 2010 + "error": "access_denied", 2011 + "error_description": "Passkey verification failed." 2012 + })), 2013 + ) 2014 + .into_response(); 2015 + } 2016 + }; 2017 + 2018 + if let Err(e) = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await { 2019 + tracing::warn!(error = %e, "Failed to delete authentication state"); 2020 + } 2021 + 2022 + if auth_result.needs_update() 2023 + && let Err(e) = crate::auth::webauthn::update_passkey_counter( 2024 + &state.db, 2025 + auth_result.cred_id(), 2026 + auth_result.counter(), 2027 + ) 2028 + .await 2029 + { 2030 + tracing::warn!(error = %e, "Failed to update passkey counter"); 2031 + } 2032 + 2033 + tracing::info!(did = %did, "Passkey authentication successful"); 2034 + 2035 + let has_totp = crate::api::server::has_totp_enabled(&state, &did).await; 2036 + if has_totp { 2037 + return Json(serde_json::json!({ 2038 + "needs_totp": true 2039 + })) 2040 + .into_response(); 2041 + } 2042 + 2043 + let user = sqlx::query!( 2044 + "SELECT two_factor_enabled, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", id FROM users WHERE did = $1", 2045 + did 2046 + ) 2047 + .fetch_optional(&state.db) 2048 + .await; 2049 + 2050 + if let Ok(Some(user)) = user 2051 + && user.two_factor_enabled 2052 + { 2053 + let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; 2054 + match db::create_2fa_challenge(&state.db, &did, &form.request_uri).await { 2055 + Ok(challenge) => { 2056 + let hostname = 2057 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 2058 + if let Err(e) = 2059 + enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await 2060 + { 2061 + tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification"); 2062 + } 2063 + let channel_name = channel_display_name(user.preferred_comms_channel); 2064 + return Json(serde_json::json!({ 2065 + "needs_2fa": true, 2066 + "channel": channel_name 2067 + })) 2068 + .into_response(); 2069 + } 2070 + Err(_) => { 2071 + return ( 2072 + StatusCode::INTERNAL_SERVER_ERROR, 2073 + Json(serde_json::json!({ 2074 + "error": "server_error", 2075 + "error_description": "An error occurred." 2076 + })), 2077 + ) 2078 + .into_response(); 2079 + } 2080 + } 2081 + } 2082 + 2083 let device_id = extract_device_cookie(&headers); 2084 + let requested_scope_str = request_data 2085 + .parameters 2086 + .scope 2087 + .as_deref() 2088 + .unwrap_or("atproto"); 2089 + let requested_scopes: Vec<String> = requested_scope_str 2090 + .split_whitespace() 2091 + .map(|s| s.to_string()) 2092 + .collect(); 2093 + 2094 + let needs_consent = db::should_show_consent( 2095 + &state.db, 2096 + &did, 2097 + &request_data.parameters.client_id, 2098 + &requested_scopes, 2099 + ) 2100 + .await 2101 + .unwrap_or(true); 2102 + 2103 + if needs_consent { 2104 + let consent_url = format!( 2105 + "/#/oauth/consent?request_uri={}", 2106 + url_encode(&form.request_uri) 2107 + ); 2108 + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); 2109 + } 2110 + 2111 + let code = Code::generate(); 2112 if db::update_authorization_request( 2113 &state.db, 2114 &form.request_uri, 2115 + &did, 2116 device_id.as_deref(), 2117 &code.0, 2118 ) 2119 .await 2120 .is_err() 2121 { 2122 + return ( 2123 StatusCode::INTERNAL_SERVER_ERROR, 2124 + Json(serde_json::json!({ 2125 + "error": "server_error", 2126 + "error_description": "An error occurred." 2127 + })), 2128 + ) 2129 + .into_response(); 2130 } 2131 + 2132 let redirect_url = build_success_redirect( 2133 &request_data.parameters.redirect_uri, 2134 &code.0, 2135 request_data.parameters.state.as_deref(), 2136 request_data.parameters.response_mode.as_deref(), 2137 ); 2138 + 2139 Json(serde_json::json!({ 2140 "redirect_uri": redirect_url 2141 }))