Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Security key & totp support

+4989 -37
+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 117 checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" 118 118 119 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]] 120 159 name = "assert-json-diff" 121 160 version = "2.0.2" 122 161 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 778 817 checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 779 818 780 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]] 781 831 name = "bcrypt" 782 832 version = "0.17.1" 783 833 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1217 1267 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 1218 1268 1219 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]] 1220 1276 name = "cordyceps" 1221 1277 version = "0.3.4" 1222 1278 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1561 1617 ] 1562 1618 1563 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]] 1564 1634 name = "deranged" 1565 1635 version = "0.5.5" 1566 1636 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3873 3943 ] 3874 3944 3875 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]] 3876 3955 name = "once_cell" 3877 3956 version = "1.21.3" 3878 3957 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4046 4125 "structmeta", 4047 4126 "syn 2.0.111", 4048 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" 4049 4134 4050 4135 [[package]] 4051 4136 name = "pem" ··· 4358 4443 ] 4359 4444 4360 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]] 4361 4463 name = "quanta" 4362 4464 version = "0.12.6" 4363 4465 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4746 4848 ] 4747 4849 4748 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]] 4749 4860 name = "rustix" 4750 4861 version = "1.1.2" 4751 4862 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5037 5148 ] 5038 5149 5039 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]] 5040 5161 name = "serde_core" 5041 5162 version = "1.0.228" 5042 5163 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6040 6161 ] 6041 6162 6042 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]] 6043 6180 name = "tower" 6044 6181 version = "0.5.2" 6045 6182 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6226 6363 "thiserror 2.0.17", 6227 6364 "tokio", 6228 6365 "tokio-tungstenite", 6366 + "totp-rs", 6229 6367 "tower-http", 6230 6368 "tracing", 6231 6369 "tracing-subscriber", 6232 6370 "urlencoding", 6233 6371 "uuid", 6372 + "webauthn-rs", 6373 + "webauthn-rs-proto", 6234 6374 "wiremock", 6235 6375 ] 6236 6376 ··· 6416 6556 "getrandom 0.3.4", 6417 6557 "js-sys", 6418 6558 "rand 0.9.2", 6559 + "serde_core", 6560 + "sha1_smol", 6419 6561 "wasm-bindgen", 6420 6562 ] 6421 6563 ··· 6575 6717 ] 6576 6718 6577 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]] 6578 6788 name = "webpage" 6579 6789 version = "2.0.1" 6580 6790 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7082 7292 version = "0.6.2" 7083 7293 source = "registry+https://github.com/rust-lang/crates.io-index" 7084 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 + ] 7085 7312 7086 7313 [[package]] 7087 7314 name = "xattr"
+4 -1
Cargo.toml
··· 47 47 tracing-subscriber = "0.3.22" 48 48 tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } 49 49 urlencoding = "2.1" 50 - uuid = { version = "1.19.0", features = ["v4", "fast-rng"] } 50 + uuid = { version = "1.19.0", features = ["v4", "v5", "fast-rng"] } 51 51 iroh-car = "0.5.1" 52 52 image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } 53 53 redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } ··· 56 56 metrics = "0.24" 57 57 metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] } 58 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" 59 62 [features] 60 63 external-infra = [] 61 64 [dev-dependencies]
+6
frontend/src/App.svelte
··· 17 17 import OAuthLogin from './routes/OAuthLogin.svelte' 18 18 import OAuthAccounts from './routes/OAuthAccounts.svelte' 19 19 import OAuth2FA from './routes/OAuth2FA.svelte' 20 + import OAuthTotp from './routes/OAuthTotp.svelte' 20 21 import OAuthError from './routes/OAuthError.svelte' 22 + import Security from './routes/Security.svelte' 21 23 22 24 const auth = getAuthState() 23 25 ··· 59 61 return OAuthAccounts 60 62 case '/oauth/2fa': 61 63 return OAuth2FA 64 + case '/oauth/totp': 65 + return OAuthTotp 62 66 case '/oauth/error': 63 67 return OAuthError 68 + case '/security': 69 + return Security 64 70 default: 65 71 return auth.session ? Dashboard : Login 66 72 }
+76
frontend/src/lib/api.ts
··· 493 493 body: { repo, collection, rkey }, 494 494 }) 495 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 + }, 496 572 }
+4
frontend/src/routes/Dashboard.svelte
··· 155 155 <h3>Account Settings</h3> 156 156 <p>Email, password, handle, and more</p> 157 157 </a> 158 + <a href="#/security" class="nav-card"> 159 + <h3>Security</h3> 160 + <p>Two-factor authentication</p> 161 + </a> 158 162 <a href="#/notifications" class="nav-card"> 159 163 <h3>Notification Preferences</h3> 160 164 <p>Discord, Telegram, Signal channels</p>
+5
frontend/src/routes/OAuthAccounts.svelte
··· 73 73 return 74 74 } 75 75 76 + if (data.needs_totp) { 77 + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 78 + return 79 + } 80 + 76 81 if (data.needs_2fa) { 77 82 navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 78 83 return
+308 -1
frontend/src/routes/OAuthLogin.svelte
··· 6 6 let rememberDevice = $state(false) 7 7 let submitting = $state(false) 8 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 + }) 9 19 10 20 function getRequestUri(): string | null { 11 21 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') ··· 24 34 } 25 35 }) 26 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 + 27 231 async function handleSubmit(e: Event) { 28 232 e.preventDefault() 29 233 const requestUri = getRequestUri() ··· 55 259 if (!response.ok) { 56 260 error = data.error_description || data.error || 'Login failed' 57 261 submitting = false 262 + return 263 + } 264 + 265 + if (data.needs_totp) { 266 + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 58 267 return 59 268 } 60 269 ··· 106 315 107 316 <div class="oauth-login-container"> 108 317 <h1>Sign In</h1> 109 - <p class="subtitle">Sign in to continue to the application</p> 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> 110 325 111 326 {#if error} 112 327 <div class="error">{error}</div> ··· 126 341 /> 127 342 </div> 128 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 + 129 374 <div class="field"> 130 375 <label for="password">Password</label> 131 376 <input ··· 265 510 266 511 .submit-btn:hover:not(:disabled) { 267 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; 268 575 } 269 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 3 pub mod email; 4 4 pub mod invite; 5 5 pub mod meta; 6 + pub mod passkeys; 6 7 pub mod password; 7 8 pub mod service_auth; 8 9 pub mod session; 9 10 pub mod signing_key; 11 + pub mod totp; 10 12 11 13 pub use account_status::{ 12 14 activate_account, check_account_status, deactivate_account, delete_account, ··· 16 18 pub use email::{confirm_email, request_email_update, update_email}; 17 19 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 18 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 + }; 19 25 pub use password::{change_password, request_password_reset, reset_password}; 20 26 pub use service_auth::get_service_auth; 21 27 pub use session::{ ··· 23 29 resend_verification, revoke_session, 24 30 }; 25 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 11 pub mod scope_check; 12 12 pub mod service; 13 13 pub mod token; 14 + pub mod totp; 14 15 pub mod verify; 16 + pub mod webauthn; 15 17 16 18 pub use extractor::{ 17 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 279 get(api::server::get_account_invite_codes), 280 280 ) 281 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( 282 322 "/xrpc/com.atproto.admin.getInviteCodes", 283 323 get(api::admin::get_invite_codes), 284 324 ) ··· 358 398 .route( 359 399 "/oauth/authorize/2fa", 360 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), 361 417 ) 362 418 .route( 363 419 "/oauth/authorize/deny",
+759 -35
src/oauth/endpoints/authorize.rs
··· 486 486 if !password_valid { 487 487 return show_login_error("Invalid handle/email or password.", json_response); 488 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 + } 489 508 if user.two_factor_enabled { 490 509 let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; 491 510 match db::create_2fa_challenge(&state.db, &user.did, &form.request_uri).await { ··· 744 763 "access_denied", 745 764 "Please verify your account before logging in.", 746 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(); 747 783 } 748 784 if user.two_factor_enabled { 749 785 let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; ··· 1323 1359 "Too many attempts. Please try again later.", 1324 1360 ); 1325 1361 } 1326 - let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await { 1327 - Ok(Some(c)) => c, 1362 + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { 1363 + Ok(Some(d)) => d, 1328 1364 Ok(None) => { 1329 1365 return json_error( 1330 1366 StatusCode::BAD_REQUEST, 1331 1367 "invalid_request", 1332 - "No 2FA challenge found. Please start over.", 1368 + "Authorization request not found.", 1333 1369 ); 1334 1370 } 1335 1371 Err(_) => { 1336 1372 return json_error( 1337 1373 StatusCode::INTERNAL_SERVER_ERROR, 1338 1374 "server_error", 1339 - "An error occurred. Please try again.", 1375 + "An error occurred.", 1340 1376 ); 1341 1377 } 1342 1378 }; 1343 - if challenge.expires_at < Utc::now() { 1344 - let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; 1379 + if request_data.expires_at < Utc::now() { 1380 + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; 1345 1381 return json_error( 1346 1382 StatusCode::BAD_REQUEST, 1347 1383 "invalid_request", 1348 - "2FA code has expired. Please start over.", 1384 + "Authorization request has expired.", 1349 1385 ); 1350 1386 } 1351 - if challenge.attempts >= MAX_2FA_ATTEMPTS { 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 + } 1352 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 { 1353 1463 return json_error( 1354 - StatusCode::FORBIDDEN, 1355 - "access_denied", 1356 - "Too many failed attempts. Please start over.", 1464 + StatusCode::BAD_REQUEST, 1465 + "invalid_request", 1466 + "No 2FA challenge found. Please start over.", 1357 1467 ); 1358 1468 } 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; 1469 + let totp_valid = 1470 + crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await; 1471 + if !totp_valid { 1367 1472 return json_error( 1368 1473 StatusCode::FORBIDDEN, 1369 1474 "invalid_code", 1370 1475 "Invalid verification code. Please try again.", 1371 1476 ); 1372 1477 } 1373 - let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; 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 + 1374 1657 let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { 1375 - Ok(Some(d)) => d, 1658 + Ok(Some(data)) => data, 1376 1659 Ok(None) => { 1377 - return json_error( 1660 + return ( 1378 1661 StatusCode::BAD_REQUEST, 1379 - "invalid_request", 1380 - "Authorization request not found.", 1381 - ); 1662 + Json(serde_json::json!({ 1663 + "error": "invalid_request", 1664 + "error_description": "Invalid or expired request_uri." 1665 + })), 1666 + ) 1667 + .into_response(); 1382 1668 } 1383 1669 Err(_) => { 1384 - return json_error( 1670 + return ( 1385 1671 StatusCode::INTERNAL_SERVER_ERROR, 1386 - "server_error", 1387 - "An error occurred.", 1388 - ); 1672 + Json(serde_json::json!({ 1673 + "error": "server_error", 1674 + "error_description": "An error occurred." 1675 + })), 1676 + ) 1677 + .into_response(); 1389 1678 } 1390 1679 }; 1391 - let code = Code::generate(); 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 + 1392 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(); 1393 2112 if db::update_authorization_request( 1394 2113 &state.db, 1395 2114 &form.request_uri, 1396 - &challenge.did, 2115 + &did, 1397 2116 device_id.as_deref(), 1398 2117 &code.0, 1399 2118 ) 1400 2119 .await 1401 2120 .is_err() 1402 2121 { 1403 - return json_error( 2122 + return ( 1404 2123 StatusCode::INTERNAL_SERVER_ERROR, 1405 - "server_error", 1406 - "An error occurred. Please try again.", 1407 - ); 2124 + Json(serde_json::json!({ 2125 + "error": "server_error", 2126 + "error_description": "An error occurred." 2127 + })), 2128 + ) 2129 + .into_response(); 1408 2130 } 2131 + 1409 2132 let redirect_url = build_success_redirect( 1410 2133 &request_data.parameters.redirect_uri, 1411 2134 &code.0, 1412 2135 request_data.parameters.state.as_deref(), 1413 2136 request_data.parameters.response_mode.as_deref(), 1414 2137 ); 2138 + 1415 2139 Json(serde_json::json!({ 1416 2140 "redirect_uri": redirect_url 1417 2141 }))