From 45b6b58ad68e5e7db845ad992089d6debb866223 Mon Sep 17 00:00:00 2001 From: lewis Date: Sat, 20 Dec 2025 21:41:29 +0200 Subject: [PATCH] Security key & totp support --- ...5a0bbc0c2ba75977d44c5501831a828337e9b.json | 22 + ...d43d1c4f8614dbf60381bf847e002d41afc12.json | 76 ++ ...3f55ed4ff5b918de0165b9690fcaff3975cc9.json | 16 + ...b6634d2b1a746222ec5cc41b69d12868cf614.json | 12 + ...71ba59256722b9750ec1da4747f3d82a2a00d.json | 34 + ...f924c435dbee0246662a36afa5cd40d674f74.json | 18 + ...4892bb6c9d5a96f28e6329aa7a3265bb31147.json | 14 + ...36173da716b17ed172f276ab3ae3608d79d76.json | 22 + ...a27c11c8eb1af132fc9ac54d5b67b518186cb.json | 19 + ...f46ce6296b5c647e2c7c8c46437c616545b81.json | 46 + ...a0c0257785f05d1d1b224826b90a71e59bce0.json | 14 + ...9cdb3f34882fdec7e0398b306a7d00eac8aa3.json | 15 + ...030c8617cb9d82c2a2bb26f6d9984bf19abc0.json | 22 + ...bed91c541376ebf1edc33f15b39ab5fef631c.json | 14 + ...2cd0e70a042ba80ad0c4ad31b1739f11a7338.json | 22 + ...1b48d091428b8f5a10dbaf97a6ca254a07fd3.json | 76 ++ ...3ea5efc70ce91b8b841316e500dc97ee5df0a.json | 14 + ...d58397bbf306c249bffd286457e471c00b745.json | 22 + ...6fb8806724139e33b229a08749391623b806a.json | 28 + ...9acb352cc4030fe13b49b4124cd7c7e9b360b.json | 14 + ...fc9f01fe951a8485ab820a224ab4c76d0c45c.json | 58 ++ ...e6caf56637669a91f8972c0d46a12c8a8b21c.json | 22 + ...10bddf39712adffcf9f5ea0c8399f4d39a7e9.json | 15 + ...89415a4cbdb0de95703bd064ea0f5d635980d.json | 15 + ...0840d2557153e4b81393476a443f3d4e75fed.json | 18 + ...297fa42ebae9c876c90f42ecf7b88b2f803af.json | 14 + Cargo.lock | 227 +++++ Cargo.toml | 5 +- frontend/src/App.svelte | 6 + frontend/src/lib/api.ts | 76 ++ frontend/src/routes/Dashboard.svelte | 4 + frontend/src/routes/OAuthAccounts.svelte | 5 + frontend/src/routes/OAuthLogin.svelte | 309 +++++- frontend/src/routes/OAuthTotp.svelte | 225 +++++ frontend/src/routes/Security.svelte | 897 ++++++++++++++++++ migrations/20251223_add_passkeys_totp.sql | 42 + src/api/server/mod.rs | 10 + src/api/server/passkeys.rs | 377 ++++++++ src/api/server/totp.rs | 749 +++++++++++++++ src/auth/mod.rs | 2 + src/auth/totp.rs | 194 ++++ src/auth/webauthn.rs | 386 ++++++++ src/lib.rs | 56 ++ src/oauth/endpoints/authorize.rs | 822 +++++++++++++++- 44 files changed, 5003 insertions(+), 51 deletions(-) create mode 100644 .sqlx/query-0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b.json create mode 100644 .sqlx/query-23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12.json create mode 100644 .sqlx/query-2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9.json create mode 100644 .sqlx/query-2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614.json create mode 100644 .sqlx/query-2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d.json create mode 100644 .sqlx/query-418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74.json create mode 100644 .sqlx/query-41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147.json create mode 100644 .sqlx/query-470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76.json create mode 100644 .sqlx/query-4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb.json create mode 100644 .sqlx/query-513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81.json create mode 100644 .sqlx/query-6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0.json create mode 100644 .sqlx/query-6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3.json create mode 100644 .sqlx/query-76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0.json create mode 100644 .sqlx/query-80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c.json create mode 100644 .sqlx/query-a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338.json create mode 100644 .sqlx/query-aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3.json create mode 100644 .sqlx/query-b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a.json create mode 100644 .sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json create mode 100644 .sqlx/query-cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a.json create mode 100644 .sqlx/query-d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b.json create mode 100644 .sqlx/query-e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c.json create mode 100644 .sqlx/query-e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c.json create mode 100644 .sqlx/query-e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9.json create mode 100644 .sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json create mode 100644 .sqlx/query-eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed.json create mode 100644 .sqlx/query-f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af.json create mode 100644 frontend/src/routes/OAuthTotp.svelte create mode 100644 frontend/src/routes/Security.svelte create mode 100644 migrations/20251223_add_passkeys_totp.sql create mode 100644 src/api/server/passkeys.rs create mode 100644 src/api/server/totp.rs create mode 100644 src/auth/totp.rs create mode 100644 src/auth/webauthn.rs diff --git a/.sqlx/query-0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b.json b/.sqlx/query-0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b.json new file mode 100644 index 0000000..26860bb --- /dev/null +++ b/.sqlx/query-0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "state_json", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b" +} diff --git a/.sqlx/query-23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12.json b/.sqlx/query-23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12.json new file mode 100644 index 0000000..d93c643 --- /dev/null +++ b/.sqlx/query-23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "credential_id", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "public_key", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "sign_count", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "last_used", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "friendly_name", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "aaguid", + "type_info": "Bytea" + }, + { + "ordinal": 9, + "name": "transports", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12" +} diff --git a/.sqlx/query-2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9.json b/.sqlx/query-2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9.json new file mode 100644 index 0000000..8c9a6eb --- /dev/null +++ b/.sqlx/query-2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Bytea", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9" +} diff --git a/.sqlx/query-2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614.json b/.sqlx/query-2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614.json new file mode 100644 index 0000000..fbd436d --- /dev/null +++ b/.sqlx/query-2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM webauthn_challenges WHERE expires_at < NOW()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614" +} diff --git a/.sqlx/query-2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d.json b/.sqlx/query-2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d.json new file mode 100644 index 0000000..5f03438 --- /dev/null +++ b/.sqlx/query-2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "secret_encrypted", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "encryption_version", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d" +} diff --git a/.sqlx/query-418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74.json b/.sqlx/query-418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74.json new file mode 100644 index 0000000..68d4f3a --- /dev/null +++ b/.sqlx/query-418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'authentication', $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74" +} diff --git a/.sqlx/query-41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147.json b/.sqlx/query-41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147.json new file mode 100644 index 0000000..21cb4ad --- /dev/null +++ b/.sqlx/query-41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM user_totp WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147" +} diff --git a/.sqlx/query-470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76.json b/.sqlx/query-470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76.json new file mode 100644 index 0000000..04e2792 --- /dev/null +++ b/.sqlx/query-470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76" +} diff --git a/.sqlx/query-4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb.json b/.sqlx/query-4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb.json new file mode 100644 index 0000000..4d69788 --- /dev/null +++ b/.sqlx/query-4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea", + "Bytea", + "Text", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb" +} diff --git a/.sqlx/query-513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81.json b/.sqlx/query-513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81.json new file mode 100644 index 0000000..d65d136 --- /dev/null +++ b/.sqlx/query-513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT two_factor_enabled, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", id FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "two_factor_enabled", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "preferred_comms_channel: CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81" +} diff --git a/.sqlx/query-6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0.json b/.sqlx/query-6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0.json new file mode 100644 index 0000000..8f7519e --- /dev/null +++ b/.sqlx/query-6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM backup_codes WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0" +} diff --git a/.sqlx/query-6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3.json b/.sqlx/query-6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3.json new file mode 100644 index 0000000..8ca1f74 --- /dev/null +++ b/.sqlx/query-6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3" +} diff --git a/.sqlx/query-76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0.json b/.sqlx/query-76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0.json new file mode 100644 index 0000000..9c6a460 --- /dev/null +++ b/.sqlx/query-76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "state_json", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0" +} diff --git a/.sqlx/query-80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c.json b/.sqlx/query-80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c.json new file mode 100644 index 0000000..0493a9b --- /dev/null +++ b/.sqlx/query-80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'authentication'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c" +} diff --git a/.sqlx/query-a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338.json b/.sqlx/query-a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338.json new file mode 100644 index 0000000..3dc923b --- /dev/null +++ b/.sqlx/query-a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT did FROM users WHERE handle = $1 OR email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338" +} diff --git a/.sqlx/query-aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3.json b/.sqlx/query-aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3.json new file mode 100644 index 0000000..3a0db79 --- /dev/null +++ b/.sqlx/query-aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "credential_id", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "public_key", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "sign_count", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "last_used", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "friendly_name", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "aaguid", + "type_info": "Bytea" + }, + { + "ordinal": 9, + "name": "transports", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3" +} diff --git a/.sqlx/query-b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a.json b/.sqlx/query-b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a.json new file mode 100644 index 0000000..ebd759a --- /dev/null +++ b/.sqlx/query-b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'registration'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a" +} diff --git a/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json b/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json new file mode 100644 index 0000000..ab84790 --- /dev/null +++ b/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_hash FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "password_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745" +} diff --git a/.sqlx/query-cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a.json b/.sqlx/query-cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a.json new file mode 100644 index 0000000..3477e05 --- /dev/null +++ b/.sqlx/query-cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "code_hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a" +} diff --git a/.sqlx/query-d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b.json b/.sqlx/query-d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b.json new file mode 100644 index 0000000..9ee1934 --- /dev/null +++ b/.sqlx/query-d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_totp SET last_used = NOW() WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b" +} diff --git a/.sqlx/query-e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c.json b/.sqlx/query-e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c.json new file mode 100644 index 0000000..023ba3c --- /dev/null +++ b/.sqlx/query-e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "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 ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "deactivated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "takedown_ref", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "discord_verified", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "telegram_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "signal_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + false, + false + ] + }, + "hash": "e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c" +} diff --git a/.sqlx/query-e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c.json b/.sqlx/query-e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c.json new file mode 100644 index 0000000..999575a --- /dev/null +++ b/.sqlx/query-e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT verified FROM user_totp WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c" +} diff --git a/.sqlx/query-e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9.json b/.sqlx/query-e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9.json new file mode 100644 index 0000000..1b87d57 --- /dev/null +++ b/.sqlx/query-e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE backup_codes SET used_at = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9" +} diff --git a/.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json b/.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json new file mode 100644 index 0000000..186dc84 --- /dev/null +++ b/.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d" +} diff --git a/.sqlx/query-eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed.json b/.sqlx/query-eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed.json new file mode 100644 index 0000000..a82ffa4 --- /dev/null +++ b/.sqlx/query-eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'registration', $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed" +} diff --git a/.sqlx/query-f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af.json b/.sqlx/query-f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af.json new file mode 100644 index 0000000..5eda225 --- /dev/null +++ b/.sqlx/query-f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af" +} diff --git a/Cargo.lock b/Cargo.lock index 66dc046..5e97bb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,45 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -777,6 +816,17 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "base64urlsafedata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bcrypt" version = "0.17.1" @@ -1216,6 +1266,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "cordyceps" version = "0.3.4" @@ -1560,6 +1616,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.5" @@ -3872,6 +3942,15 @@ dependencies = [ "libc", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -4047,6 +4126,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem" version = "3.0.6" @@ -4357,6 +4442,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "qrcodegen-image" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221b7eace1aef8c95d65dbe09fb7a1a43d006045394a89afba6997721fcb7708" +dependencies = [ + "base64 0.22.1", + "image", + "qrcodegen", +] + [[package]] name = "quanta" version = "0.12.6" @@ -4745,6 +4847,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.2" @@ -5036,6 +5147,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6039,6 +6160,22 @@ dependencies = [ "tonic", ] +[[package]] +name = "totp-rs" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72" +dependencies = [ + "base32", + "constant_time_eq", + "hmac", + "qrcodegen-image", + "sha1", + "sha2", + "url", + "urlencoding", +] + [[package]] name = "tower" version = "0.5.2" @@ -6226,11 +6363,14 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-tungstenite", + "totp-rs", "tower-http", "tracing", "tracing-subscriber", "urlencoding", "uuid", + "webauthn-rs", + "webauthn-rs-proto", "wiremock", ] @@ -6416,6 +6556,8 @@ dependencies = [ "getrandom 0.3.4", "js-sys", "rand 0.9.2", + "serde_core", + "sha1_smol", "wasm-bindgen", ] @@ -6574,6 +6716,74 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.2", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpage" version = "2.0.1" @@ -7083,6 +7293,23 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index b2feb92..0392841 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ tracing = "0.1.43" tracing-subscriber = "0.3.22" tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } urlencoding = "2.1" -uuid = { version = "1.19.0", features = ["v4", "fast-rng"] } +uuid = { version = "1.19.0", features = ["v4", "v5", "fast-rng"] } iroh-car = "0.5.1" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } @@ -56,6 +56,9 @@ hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } metrics = "0.24" metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] } bs58 = "0.5.1" +totp-rs = { version = "5", features = ["qr"] } +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } +webauthn-rs-proto = "0.5.4" [features] external-infra = [] [dev-dependencies] diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index b6640c2..bd291e9 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -17,7 +17,9 @@ import OAuthLogin from './routes/OAuthLogin.svelte' import OAuthAccounts from './routes/OAuthAccounts.svelte' import OAuth2FA from './routes/OAuth2FA.svelte' + import OAuthTotp from './routes/OAuthTotp.svelte' import OAuthError from './routes/OAuthError.svelte' + import Security from './routes/Security.svelte' const auth = getAuthState() @@ -59,8 +61,12 @@ return OAuthAccounts case '/oauth/2fa': return OAuth2FA + case '/oauth/totp': + return OAuthTotp case '/oauth/error': return OAuthError + case '/security': + return Security default: return auth.session ? Dashboard : Login } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dc56561..c918a1f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -493,4 +493,80 @@ export const api = { body: { repo, collection, rkey }, }) }, + + async getTotpStatus(token: string): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { + return xrpc('com.atproto.server.getTotpStatus', { token }) + }, + + async createTotpSecret(token: string): Promise<{ uri: string; qrBase64: string }> { + return xrpc('com.atproto.server.createTotpSecret', { method: 'POST', token }) + }, + + async enableTotp(token: string, code: string): Promise<{ success: boolean; backupCodes: string[] }> { + return xrpc('com.atproto.server.enableTotp', { + method: 'POST', + token, + body: { code }, + }) + }, + + async disableTotp(token: string, password: string, code: string): Promise<{ success: boolean }> { + return xrpc('com.atproto.server.disableTotp', { + method: 'POST', + token, + body: { password, code }, + }) + }, + + async regenerateBackupCodes(token: string, password: string, code: string): Promise<{ backupCodes: string[] }> { + return xrpc('com.atproto.server.regenerateBackupCodes', { + method: 'POST', + token, + body: { password, code }, + }) + }, + + async startPasskeyRegistration(token: string, friendlyName?: string): Promise<{ options: unknown }> { + return xrpc('com.atproto.server.startPasskeyRegistration', { + method: 'POST', + token, + body: { friendlyName }, + }) + }, + + async finishPasskeyRegistration(token: string, credential: unknown, friendlyName?: string): Promise<{ id: string; credentialId: string }> { + return xrpc('com.atproto.server.finishPasskeyRegistration', { + method: 'POST', + token, + body: { credential, friendlyName }, + }) + }, + + async listPasskeys(token: string): Promise<{ + passkeys: Array<{ + id: string + credentialId: string + friendlyName: string | null + createdAt: string + lastUsed: string | null + }> + }> { + return xrpc('com.atproto.server.listPasskeys', { token }) + }, + + async deletePasskey(token: string, id: string): Promise { + await xrpc('com.atproto.server.deletePasskey', { + method: 'POST', + token, + body: { id }, + }) + }, + + async updatePasskey(token: string, id: string, friendlyName: string): Promise { + await xrpc('com.atproto.server.updatePasskey', { + method: 'POST', + token, + body: { id, friendlyName }, + }) + }, } diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index afa8518..4c30130 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -155,6 +155,10 @@

Account Settings

Email, password, handle, and more

+ +

Security

+

Two-factor authentication

+

Notification Preferences

Discord, Telegram, Signal channels

diff --git a/frontend/src/routes/OAuthAccounts.svelte b/frontend/src/routes/OAuthAccounts.svelte index 3341087..0a31bd5 100644 --- a/frontend/src/routes/OAuthAccounts.svelte +++ b/frontend/src/routes/OAuthAccounts.svelte @@ -73,6 +73,11 @@ return } + if (data.needs_totp) { + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) + return + } + if (data.needs_2fa) { navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) return diff --git a/frontend/src/routes/OAuthLogin.svelte b/frontend/src/routes/OAuthLogin.svelte index 9f4ffbb..c2b7373 100644 --- a/frontend/src/routes/OAuthLogin.svelte +++ b/frontend/src/routes/OAuthLogin.svelte @@ -6,6 +6,16 @@ let rememberDevice = $state(false) let submitting = $state(false) let error = $state(null) + let hasPasskeys = $state(false) + let hasTotp = $state(false) + let checkingSecurityStatus = $state(false) + let securityStatusChecked = $state(false) + let passkeySupported = $state(false) + let clientName = $state(null) + + $effect(() => { + passkeySupported = window.PublicKeyCredential !== undefined + }) function getRequestUri(): string | null { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') @@ -24,6 +34,200 @@ } }) + $effect(() => { + fetchAuthRequestInfo() + }) + + async function fetchAuthRequestInfo() { + const requestUri = getRequestUri() + if (!requestUri) return + + try { + const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, { + headers: { 'Accept': 'application/json' } + }) + if (response.ok) { + const data = await response.json() + if (data.login_hint && !username) { + username = data.login_hint + } + if (data.client_name) { + clientName = data.client_name + } + } + } catch { + // Ignore errors fetching auth info + } + } + + let checkTimeout: ReturnType | null = null + + $effect(() => { + if (checkTimeout) { + clearTimeout(checkTimeout) + } + hasPasskeys = false + hasTotp = false + securityStatusChecked = false + if (username.length >= 3) { + checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500) + } + }) + + async function checkUserSecurityStatus() { + if (!username || checkingSecurityStatus) return + checkingSecurityStatus = true + try { + const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`) + if (response.ok) { + const data = await response.json() + hasPasskeys = passkeySupported && data.hasPasskeys === true + hasTotp = data.hasTotp === true + securityStatusChecked = true + } + } catch { + hasPasskeys = false + hasTotp = false + } finally { + checkingSecurityStatus = false + } + } + + + async function handlePasskeyLogin() { + const requestUri = getRequestUri() + if (!requestUri || !username) { + error = 'Missing required parameters' + return + } + + submitting = true + error = null + + try { + const startResponse = await fetch('/oauth/passkey/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + request_uri: requestUri, + identifier: username + }) + }) + + if (!startResponse.ok) { + const data = await startResponse.json() + error = data.error_description || data.error || 'Failed to start passkey login' + submitting = false + return + } + + const { options } = await startResponse.json() + + const credential = await navigator.credentials.get({ + publicKey: prepareCredentialRequestOptions(options.publicKey) + }) as PublicKeyCredential | null + + if (!credential) { + error = 'Passkey authentication was cancelled' + submitting = false + return + } + + const assertionResponse = credential.response as AuthenticatorAssertionResponse + const credentialData = { + id: credential.id, + type: credential.type, + rawId: arrayBufferToBase64Url(credential.rawId), + response: { + clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON), + authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData), + signature: arrayBufferToBase64Url(assertionResponse.signature), + userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null + } + } + + const finishResponse = await fetch('/oauth/passkey/finish', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + request_uri: requestUri, + credential: credentialData + }) + }) + + const data = await finishResponse.json() + + if (!finishResponse.ok) { + error = data.error_description || data.error || 'Passkey authentication failed' + submitting = false + return + } + + if (data.needs_totp) { + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) + return + } + + if (data.needs_2fa) { + navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) + return + } + + if (data.redirect_uri) { + window.location.href = data.redirect_uri + return + } + + error = 'Unexpected response from server' + submitting = false + } catch (e) { + console.error('Passkey login error:', e) + if (e instanceof DOMException && e.name === 'NotAllowedError') { + error = 'Passkey authentication was cancelled' + } else { + error = `Failed to authenticate with passkey: ${e instanceof Error ? e.message : String(e)}` + } + submitting = false + } + } + + function arrayBufferToBase64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') + } + + function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer + } + + function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions { + return { + ...options, + challenge: base64UrlToArrayBuffer(options.challenge), + allowCredentials: options.allowCredentials?.map((cred: any) => ({ + ...cred, + id: base64UrlToArrayBuffer(cred.id) + })) || [] + } + } + async function handleSubmit(e: Event) { e.preventDefault() const requestUri = getRequestUri() @@ -58,6 +262,11 @@ return } + if (data.needs_totp) { + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) + return + } + if (data.needs_2fa) { navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) return @@ -106,7 +315,13 @@ + {#if securityStatusChecked && passkeySupported} + + +
+ or use password +
+ {/if} +
diff --git a/frontend/src/routes/OAuthTotp.svelte b/frontend/src/routes/OAuthTotp.svelte new file mode 100644 index 0000000..dc5935a --- /dev/null +++ b/frontend/src/routes/OAuthTotp.svelte @@ -0,0 +1,225 @@ + + +
+

Two-Factor Authentication

+

+ Enter the 6-digit code from your authenticator app, or use a backup code. +

+ + {#if error} +
{error}
+ {/if} + +
+
+ + +

+ {#if isBackupCode} + Using backup code + {:else if isTotpCode} + Using authenticator code + {:else} + 6 digits for authenticator, 8 characters for backup code + {/if} +

+
+ +
+ + +
+
+
+ + diff --git a/frontend/src/routes/Security.svelte b/frontend/src/routes/Security.svelte new file mode 100644 index 0000000..ac91a24 --- /dev/null +++ b/frontend/src/routes/Security.svelte @@ -0,0 +1,897 @@ + + +
+
+ ← Dashboard +

Security Settings

+
+ + {#if message} +
{message.text}
+ {/if} + + {#if loading} +
Loading...
+ {:else} +
+

Two-Factor Authentication

+

+ Add an extra layer of security to your account using an authenticator app like Google Authenticator, Authy, or 1Password. +

+ + {#if setupStep === 'idle'} + {#if totpEnabled} +
+ Two-factor authentication is enabled +
+ + {#if !showDisableForm && !showRegenForm} +
+ + +
+ {/if} + + {#if showRegenForm} +
+

Regenerate Backup Codes

+

This will invalidate all existing backup codes.

+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + {#if showDisableForm} +
+

Disable Two-Factor Authentication

+

This will make your account less secure.

+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + {:else} +
+ Two-factor authentication is not enabled +
+ + {/if} + {:else if setupStep === 'qr'} +
+

Step 1: Scan QR Code

+

Scan this QR code with your authenticator app:

+
+ TOTP QR Code +
+
+ Can't scan? Enter manually + {totpUri.split('secret=')[1]?.split('&')[0] || ''} +
+ +
+ {:else if setupStep === 'verify'} +
+

Step 2: Verify Setup

+

Enter the 6-digit code from your authenticator app:

+
+
+ +
+
+ + +
+
+
+ {:else if setupStep === 'backup'} +
+

Step 3: Save Backup Codes

+

+ Save these backup codes in a secure location. Each code can only be used once. + If you lose access to your authenticator app, you'll need these to sign in. +

+
+ {#each backupCodes as code} + {code} + {/each} +
+
+ + +
+
+ {/if} +
+ +
+

Passkeys

+

+ Passkeys are a secure, passwordless way to sign in using biometrics (fingerprint or face), a security key, or your device's screen lock. +

+ + {#if passkeysLoading} +
Loading passkeys...
+ {:else} + {#if passkeys.length > 0} +
+ {#each passkeys as passkey} +
+ {#if editingPasskeyId === passkey.id} +
+ +
+ + +
+
+ {:else} +
+ {passkey.friendlyName || 'Unnamed passkey'} + + Added {formatDate(passkey.createdAt)} + {#if passkey.lastUsed} + · Last used {formatDate(passkey.lastUsed)} + {/if} + +
+
+ + +
+ {/if} +
+ {/each} +
+ {:else} +
+ No passkeys registered +
+ {/if} + +
+
+ + +
+ +
+ {/if} +
+ {/if} +
+ + diff --git a/migrations/20251223_add_passkeys_totp.sql b/migrations/20251223_add_passkeys_totp.sql new file mode 100644 index 0000000..0c3b1bb --- /dev/null +++ b/migrations/20251223_add_passkeys_totp.sql @@ -0,0 +1,42 @@ +CREATE TABLE user_totp ( + did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, + secret_encrypted BYTEA NOT NULL, + encryption_version INTEGER NOT NULL DEFAULT 1, + verified BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used TIMESTAMPTZ +); + +CREATE TABLE backup_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_backup_codes_did ON backup_codes(did); + +CREATE TABLE passkeys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, + public_key BYTEA NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used TIMESTAMPTZ, + friendly_name TEXT, + aaguid BYTEA, + transports TEXT[] +); +CREATE INDEX idx_passkeys_did ON passkeys(did); + +CREATE TABLE webauthn_challenges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT NOT NULL, + challenge BYTEA NOT NULL, + challenge_type TEXT NOT NULL, + state_json TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did); diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 4be6274..1b97506 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -3,10 +3,12 @@ pub mod app_password; pub mod email; pub mod invite; pub mod meta; +pub mod passkeys; pub mod password; pub mod service_auth; pub mod session; pub mod signing_key; +pub mod totp; pub use account_status::{ activate_account, check_account_status, deactivate_account, delete_account, @@ -16,6 +18,10 @@ pub use app_password::{create_app_password, list_app_passwords, revoke_app_passw pub use email::{confirm_email, request_email_update, update_email}; pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; pub use meta::{describe_server, health, robots_txt}; +pub use passkeys::{ + delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys, + start_passkey_registration, update_passkey, +}; pub use password::{change_password, request_password_reset, reset_password}; pub use service_auth::get_service_auth; pub use session::{ @@ -23,3 +29,7 @@ pub use session::{ resend_verification, revoke_session, }; pub use signing_key::reserve_signing_key; +pub use totp::{ + create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled, + regenerate_backup_codes, verify_totp_or_backup_for_user, +}; diff --git a/src/api/server/passkeys.rs b/src/api/server/passkeys.rs new file mode 100644 index 0000000..d853fb8 --- /dev/null +++ b/src/api/server/passkeys.rs @@ -0,0 +1,377 @@ +use crate::auth::BearerAuth; +use crate::auth::webauthn::{ + self, WebAuthnConfig, delete_passkey as db_delete_passkey, delete_registration_state, + get_passkeys_for_user, load_registration_state, save_passkey, save_registration_state, + update_passkey_name as db_update_passkey_name, +}; +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{error, info, warn}; +use webauthn_rs::prelude::*; + +fn get_webauthn() -> Result)> { + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + WebAuthnConfig::new(&hostname).map_err(|e| { + error!("Failed to create WebAuthn config: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "WebAuthn configuration failed"})), + ) + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartRegistrationInput { + pub friendly_name: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartRegistrationResponse { + pub options: serde_json::Value, +} + +pub async fn start_passkey_registration( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let webauthn = match get_webauthn() { + Ok(w) => w, + Err(e) => return e.into_response(), + }; + + let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", auth.0.did) + .fetch_optional(&state.db) + .await; + + let handle = match user { + Ok(Some(row)) => row.handle, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error fetching user: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let existing_passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await { + Ok(passkeys) => passkeys, + Err(e) => { + error!("DB error fetching existing passkeys: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let exclude_credentials: Vec = existing_passkeys + .iter() + .map(|p| CredentialID::from(p.credential_id.clone())) + .collect(); + + let display_name = input.friendly_name.as_deref().unwrap_or(&handle); + + let (ccr, reg_state) = match webauthn.start_registration( + &auth.0.did, + &handle, + display_name, + exclude_credentials, + ) { + Ok(result) => result, + Err(e) => { + error!("Failed to start passkey registration: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to start registration"})), + ) + .into_response(); + } + }; + + if let Err(e) = save_registration_state(&state.db, &auth.0.did, ®_state).await { + error!("Failed to save registration state: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + let options = serde_json::to_value(&ccr).unwrap_or(json!({})); + + info!(did = %auth.0.did, "Passkey registration started"); + + Json(StartRegistrationResponse { options }).into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FinishRegistrationInput { + pub credential: serde_json::Value, + pub friendly_name: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FinishRegistrationResponse { + pub id: String, + pub credential_id: String, +} + +pub async fn finish_passkey_registration( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let webauthn = match get_webauthn() { + Ok(w) => w, + Err(e) => return e.into_response(), + }; + + let reg_state = match load_registration_state(&state.db, &auth.0.did).await { + Ok(Some(state)) => state, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "NoRegistrationInProgress", + "message": "No registration in progress. Call startPasskeyRegistration first." + })), + ) + .into_response(); + } + Err(e) => { + error!("DB error loading registration state: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let credential: RegisterPublicKeyCredential = match serde_json::from_value(input.credential) { + Ok(c) => c, + Err(e) => { + warn!("Failed to parse credential: {:?}", e); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidCredential", + "message": "Failed to parse credential response" + })), + ) + .into_response(); + } + }; + + let passkey = match webauthn.finish_registration(&credential, ®_state) { + Ok(pk) => pk, + Err(e) => { + warn!("Failed to finish passkey registration: {}", e); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "RegistrationFailed", + "message": "Failed to verify passkey registration" + })), + ) + .into_response(); + } + }; + + let passkey_id = match save_passkey( + &state.db, + &auth.0.did, + &passkey, + input.friendly_name.as_deref(), + ) + .await + { + Ok(id) => id, + Err(e) => { + error!("Failed to save passkey: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = delete_registration_state(&state.db, &auth.0.did).await { + warn!("Failed to delete registration state: {:?}", e); + } + + let credential_id_base64 = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + passkey.cred_id(), + ); + + info!(did = %auth.0.did, passkey_id = %passkey_id, "Passkey registered"); + + Json(FinishRegistrationResponse { + id: passkey_id.to_string(), + credential_id: credential_id_base64, + }) + .into_response() +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyInfo { + pub id: String, + pub credential_id: String, + pub friendly_name: Option, + pub created_at: String, + pub last_used: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListPasskeysResponse { + pub passkeys: Vec, +} + +pub async fn list_passkeys(State(state): State, auth: BearerAuth) -> Response { + let passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await { + Ok(pks) => pks, + Err(e) => { + error!("DB error fetching passkeys: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let passkey_infos: Vec = passkeys + .into_iter() + .map(|pk| PasskeyInfo { + id: pk.id.to_string(), + credential_id: pk.credential_id_base64(), + friendly_name: pk.friendly_name, + created_at: pk.created_at.to_rfc3339(), + last_used: pk.last_used.map(|dt| dt.to_rfc3339()), + }) + .collect(); + + Json(ListPasskeysResponse { + passkeys: passkey_infos, + }) + .into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeletePasskeyInput { + pub id: String, +} + +pub async fn delete_passkey( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let id: uuid::Uuid = match input.id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})), + ) + .into_response(); + } + }; + + match db_delete_passkey(&state.db, id, &auth.0.did).await { + Ok(true) => { + info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted"); + (StatusCode::OK, Json(json!({}))).into_response() + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})), + ) + .into_response(), + Err(e) => { + error!("DB error deleting passkey: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePasskeyInput { + pub id: String, + pub friendly_name: String, +} + +pub async fn update_passkey( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let id: uuid::Uuid = match input.id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})), + ) + .into_response(); + } + }; + + match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await { + Ok(true) => { + info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed"); + (StatusCode::OK, Json(json!({}))).into_response() + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})), + ) + .into_response(), + Err(e) => { + error!("DB error updating passkey: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool { + webauthn::has_passkeys(&state.db, did) + .await + .unwrap_or(false) +} diff --git a/src/api/server/totp.rs b/src/api/server/totp.rs new file mode 100644 index 0000000..1f66817 --- /dev/null +++ b/src/api/server/totp.rs @@ -0,0 +1,749 @@ +use crate::auth::BearerAuth; +use crate::auth::totp::{ + decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64, + generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format, + verify_backup_code, verify_totp_code, +}; +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{error, info, warn}; + +const ENCRYPTION_VERSION: i32 = 1; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTotpSecretResponse { + pub secret: String, + pub uri: String, + pub qr_base64: String, +} + +pub async fn create_totp_secret(State(state): State, auth: BearerAuth) -> Response { + let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(true)) = existing { + return ( + StatusCode::CONFLICT, + Json(json!({ + "error": "TotpAlreadyEnabled", + "message": "TOTP is already enabled for this account" + })), + ) + .into_response(); + } + + let secret = generate_totp_secret(); + + let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did) + .fetch_optional(&state.db) + .await; + + let handle = match handle { + Ok(Some(h)) => h, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error fetching handle: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let uri = generate_totp_uri(&secret, &handle, &hostname); + + let qr_code = match generate_qr_png_base64(&secret, &handle, &hostname) { + Ok(qr) => qr, + Err(e) => { + error!("Failed to generate QR code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})), + ) + .into_response(); + } + }; + + let encrypted_secret = match encrypt_totp_secret(&secret) { + Ok(enc) => enc, + Err(e) => { + error!("Failed to encrypt TOTP secret: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let result = sqlx::query!( + r#" + INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at) + VALUES ($1, $2, $3, false, NOW()) + ON CONFLICT (did) DO UPDATE SET + secret_encrypted = $2, + encryption_version = $3, + verified = false, + created_at = NOW(), + last_used = NULL + "#, + auth.0.did, + encrypted_secret, + ENCRYPTION_VERSION + ) + .execute(&state.db) + .await; + + if let Err(e) = result { + error!("Failed to store TOTP secret: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret); + + info!(did = %auth.0.did, "TOTP secret created (pending verification)"); + + Json(CreateTotpSecretResponse { + secret: secret_base32, + uri, + qr_base64: qr_code, + }) + .into_response() +} + +#[derive(Deserialize)] +pub struct EnableTotpInput { + pub code: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnableTotpResponse { + pub backup_codes: Vec, +} + +pub async fn enable_totp( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let totp_row = sqlx::query!( + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", + auth.0.did + ) + .fetch_optional(&state.db) + .await; + + let totp_row = match totp_row { + Ok(Some(row)) => row, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "TotpNotSetup", + "message": "Please call createTotpSecret first" + })), + ) + .into_response(); + } + Err(e) => { + error!("DB error fetching TOTP: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if totp_row.verified { + return ( + StatusCode::CONFLICT, + Json(json!({ + "error": "TotpAlreadyEnabled", + "message": "TOTP is already enabled" + })), + ) + .into_response(); + } + + let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) + { + Ok(s) => s, + Err(e) => { + error!("Failed to decrypt TOTP secret: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let code = input.code.trim(); + if !verify_totp_code(&secret, code) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "InvalidCode", + "message": "Invalid verification code" + })), + ) + .into_response(); + } + + let backup_codes = generate_backup_codes(); + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1", + auth.0.did + ) + .execute(&mut *tx) + .await + { + error!("Failed to enable TOTP: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) + .execute(&mut *tx) + .await + { + error!("Failed to clear old backup codes: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + for code in &backup_codes { + let hash = match hash_backup_code(code) { + Ok(h) => h, + Err(e) => { + error!("Failed to hash backup code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", + auth.0.did, + hash + ) + .execute(&mut *tx) + .await + { + error!("Failed to store backup code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + + if let Err(e) = tx.commit().await { + error!("Failed to commit transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len()); + + Json(EnableTotpResponse { backup_codes }).into_response() +} + +#[derive(Deserialize)] +pub struct DisableTotpInput { + pub password: String, + pub code: String, +} + +pub async fn disable_totp( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) + .fetch_optional(&state.db) + .await; + + let password_hash = match user { + Ok(Some(row)) => row.password_hash, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error fetching user: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); + if !password_valid { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "InvalidPassword", + "message": "Password is incorrect" + })), + ) + .into_response(); + } + + let totp_row = sqlx::query!( + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", + auth.0.did + ) + .fetch_optional(&state.db) + .await; + + let totp_row = match totp_row { + Ok(Some(row)) if row.verified => row, + Ok(Some(_)) | Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "TotpNotEnabled", + "message": "TOTP is not enabled for this account" + })), + ) + .into_response(); + } + Err(e) => { + error!("DB error fetching TOTP: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let code = input.code.trim(); + let code_valid = if is_backup_code_format(code) { + verify_backup_code_for_user(&state, &auth.0.did, code).await + } else { + let secret = + match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) { + Ok(s) => s, + Err(e) => { + error!("Failed to decrypt TOTP secret: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + verify_totp_code(&secret, code) + }; + + if !code_valid { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "InvalidCode", + "message": "Invalid verification code" + })), + ) + .into_response(); + } + + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did) + .execute(&mut *tx) + .await + { + error!("Failed to delete TOTP: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) + .execute(&mut *tx) + .await + { + error!("Failed to delete backup codes: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Err(e) = tx.commit().await { + error!("Failed to commit transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + info!(did = %auth.0.did, "TOTP disabled"); + + (StatusCode::OK, Json(json!({}))).into_response() +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetTotpStatusResponse { + pub enabled: bool, + pub has_backup_codes: bool, + pub backup_codes_remaining: i64, +} + +pub async fn get_totp_status(State(state): State, auth: BearerAuth) -> Response { + let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did) + .fetch_optional(&state.db) + .await; + + let enabled = match totp_row { + Ok(Some(row)) => row.verified, + Ok(None) => false, + Err(e) => { + error!("DB error fetching TOTP status: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let backup_count_row = sqlx::query!( + "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL", + auth.0.did + ) + .fetch_one(&state.db) + .await; + + let backup_count = backup_count_row.map(|r| r.count.unwrap_or(0)).unwrap_or(0); + + Json(GetTotpStatusResponse { + enabled, + has_backup_codes: backup_count > 0, + backup_codes_remaining: backup_count, + }) + .into_response() +} + +#[derive(Deserialize)] +pub struct RegenerateBackupCodesInput { + pub password: String, + pub code: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegenerateBackupCodesResponse { + pub backup_codes: Vec, +} + +pub async fn regenerate_backup_codes( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) + .fetch_optional(&state.db) + .await; + + let password_hash = match user { + Ok(Some(row)) => row.password_hash, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error fetching user: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false); + if !password_valid { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "InvalidPassword", + "message": "Password is incorrect" + })), + ) + .into_response(); + } + + let totp_row = sqlx::query!( + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", + auth.0.did + ) + .fetch_optional(&state.db) + .await; + + let totp_row = match totp_row { + Ok(Some(row)) if row.verified => row, + Ok(Some(_)) | Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "TotpNotEnabled", + "message": "TOTP must be enabled to regenerate backup codes" + })), + ) + .into_response(); + } + Err(e) => { + error!("DB error fetching TOTP: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) + { + Ok(s) => s, + Err(e) => { + error!("Failed to decrypt TOTP secret: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let code = input.code.trim(); + if !verify_totp_code(&secret, code) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "InvalidCode", + "message": "Invalid verification code" + })), + ) + .into_response(); + } + + let backup_codes = generate_backup_codes(); + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) + .execute(&mut *tx) + .await + { + error!("Failed to clear old backup codes: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + for code in &backup_codes { + let hash = match hash_backup_code(code) { + Ok(h) => h, + Err(e) => { + error!("Failed to hash backup code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", + auth.0.did, + hash + ) + .execute(&mut *tx) + .await + { + error!("Failed to store backup code: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + + if let Err(e) = tx.commit().await { + error!("Failed to commit transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + info!(did = %auth.0.did, "Backup codes regenerated"); + + Json(RegenerateBackupCodesResponse { backup_codes }).into_response() +} + +async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> bool { + let code = code.trim().to_uppercase(); + + let backup_codes = sqlx::query!( + "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL", + did + ) + .fetch_all(&state.db) + .await; + + let backup_codes = match backup_codes { + Ok(codes) => codes, + Err(e) => { + warn!("Failed to fetch backup codes: {:?}", e); + return false; + } + }; + + for row in backup_codes { + if verify_backup_code(&code, &row.code_hash) { + let _ = sqlx::query!( + "UPDATE backup_codes SET used_at = $1 WHERE id = $2", + Utc::now(), + row.id + ) + .execute(&state.db) + .await; + return true; + } + } + + false +} + +pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool { + let code = code.trim(); + + if is_backup_code_format(code) { + return verify_backup_code_for_user(state, did, code).await; + } + + let totp_row = sqlx::query!( + "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await; + + let totp_row = match totp_row { + Ok(Some(row)) if row.verified => row, + _ => return false, + }; + + let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) + { + Ok(s) => s, + Err(_) => return false, + }; + + if verify_totp_code(&secret, code) { + let _ = sqlx::query!("UPDATE user_totp SET last_used = NOW() WHERE did = $1", did) + .execute(&state.db) + .await; + return true; + } + + false +} + +pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool { + let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did) + .fetch_optional(&state.db) + .await; + + matches!(result, Ok(Some(true))) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 359b842..d86dcff 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -11,7 +11,9 @@ pub mod extractor; pub mod scope_check; pub mod service; pub mod token; +pub mod totp; pub mod verify; +pub mod webauthn; pub use extractor::{ AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken, diff --git a/src/auth/totp.rs b/src/auth/totp.rs new file mode 100644 index 0000000..58a06d2 --- /dev/null +++ b/src/auth/totp.rs @@ -0,0 +1,194 @@ +use base32::Alphabet; +use rand::RngCore; +use subtle::ConstantTimeEq; +use totp_rs::{Algorithm, TOTP}; + +const TOTP_DIGITS: usize = 6; +const TOTP_STEP: u64 = 30; +const TOTP_SECRET_LENGTH: usize = 20; + +pub fn generate_totp_secret() -> Vec { + let mut secret = vec![0u8; TOTP_SECRET_LENGTH]; + rand::thread_rng().fill_bytes(&mut secret); + secret +} + +pub fn encrypt_totp_secret(secret: &[u8]) -> Result, String> { + crate::config::encrypt_key(secret) +} + +pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result, String> { + crate::config::decrypt_key(encrypted, Some(version)) +} + +fn create_totp( + secret: Vec, + issuer: Option, + account_name: String, +) -> Result { + TOTP::new( + Algorithm::SHA1, + TOTP_DIGITS, + 1, + TOTP_STEP, + secret, + issuer, + account_name, + ) + .map_err(|e| format!("Failed to create TOTP: {}", e)) +} + +pub fn verify_totp_code(secret: &[u8], code: &str) -> bool { + let code = code.trim(); + if code.len() != TOTP_DIGITS { + return false; + } + + let Ok(totp) = create_totp(secret.to_vec(), None, String::new()) else { + return false; + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + for offset in [-1i64, 0, 1] { + let time = (now as i64 + offset * TOTP_STEP as i64) as u64; + let expected = totp.generate(time); + let is_valid: bool = code.as_bytes().ct_eq(expected.as_bytes()).into(); + if is_valid { + return true; + } + } + + false +} + +pub fn generate_totp_uri(secret: &[u8], account_name: &str, issuer: &str) -> String { + let secret_base32 = base32::encode(Alphabet::Rfc4648 { padding: false }, secret); + format!( + "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}", + urlencoding::encode(issuer), + urlencoding::encode(account_name), + secret_base32, + urlencoding::encode(issuer), + TOTP_DIGITS, + TOTP_STEP + ) +} + +pub fn generate_qr_png_base64( + secret: &[u8], + account_name: &str, + issuer: &str, +) -> Result { + use base64::{Engine, engine::general_purpose::STANDARD}; + + let totp = create_totp( + secret.to_vec(), + Some(issuer.to_string()), + account_name.to_string(), + )?; + + let qr_png = totp + .get_qr_png() + .map_err(|e| format!("Failed to generate QR code: {}", e))?; + + Ok(STANDARD.encode(qr_png)) +} + +const BACKUP_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ"; +const BACKUP_CODE_LENGTH: usize = 8; +const BACKUP_CODE_COUNT: usize = 10; +const BACKUP_CODE_BCRYPT_COST: u32 = 10; + +pub fn generate_backup_codes() -> Vec { + let mut codes = Vec::with_capacity(BACKUP_CODE_COUNT); + let mut rng = rand::thread_rng(); + + for _ in 0..BACKUP_CODE_COUNT { + let mut code = String::with_capacity(BACKUP_CODE_LENGTH); + for _ in 0..BACKUP_CODE_LENGTH { + let idx = (rng.next_u32() as usize) % BACKUP_CODE_ALPHABET.len(); + code.push(BACKUP_CODE_ALPHABET[idx] as char); + } + codes.push(code); + } + + codes +} + +pub fn hash_backup_code(code: &str) -> Result { + bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| format!("Failed to hash code: {}", e)) +} + +pub fn verify_backup_code(code: &str, hash: &str) -> bool { + bcrypt::verify(code, hash).unwrap_or(false) +} + +pub fn is_backup_code_format(code: &str) -> bool { + let code = code.trim().to_uppercase(); + code.len() == BACKUP_CODE_LENGTH + && code + .chars() + .all(|c| BACKUP_CODE_ALPHABET.contains(&(c as u8))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_totp_secret() { + let secret = generate_totp_secret(); + assert_eq!(secret.len(), TOTP_SECRET_LENGTH); + } + + #[test] + fn test_verify_totp_code() { + let secret = generate_totp_secret(); + let totp = create_totp(secret.clone(), None, String::new()).unwrap(); + let code = totp.generate_current().unwrap(); + assert!(verify_totp_code(&secret, &code)); + assert!(!verify_totp_code(&secret, "000000")); + } + + #[test] + fn test_generate_totp_uri() { + let secret = vec![0u8; 20]; + let uri = generate_totp_uri(&secret, "test@example.com", "TestPDS"); + assert!(uri.starts_with("otpauth://totp/")); + assert!(uri.contains("secret=")); + assert!(uri.contains("issuer=TestPDS")); + } + + #[test] + fn test_backup_codes() { + let codes = generate_backup_codes(); + assert_eq!(codes.len(), BACKUP_CODE_COUNT); + for code in &codes { + assert_eq!(code.len(), BACKUP_CODE_LENGTH); + assert!(is_backup_code_format(code)); + } + } + + #[test] + fn test_backup_code_hash_verify() { + let codes = generate_backup_codes(); + let code = &codes[0]; + let hash = hash_backup_code(code).unwrap(); + assert!(verify_backup_code(code, &hash)); + assert!(!verify_backup_code("WRONGCOD", &hash)); + } + + #[test] + fn test_is_backup_code_format() { + assert!(is_backup_code_format("ABCD2345")); + assert!(is_backup_code_format(" abcd2345 ")); + assert!(!is_backup_code_format("ABCD234")); + assert!(!is_backup_code_format("ABCD23456")); + assert!(!is_backup_code_format("ABCD234O")); + assert!(!is_backup_code_format("ABCD2341")); + } +} diff --git a/src/auth/webauthn.rs b/src/auth/webauthn.rs new file mode 100644 index 0000000..50c90f4 --- /dev/null +++ b/src/auth/webauthn.rs @@ -0,0 +1,386 @@ +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{Duration, Utc}; +use sqlx::{PgPool, Row}; +use uuid::Uuid; +use webauthn_rs::prelude::*; + +pub struct WebAuthnConfig { + webauthn: Webauthn, +} + +impl WebAuthnConfig { + pub fn new(hostname: &str) -> Result { + let rp_id = hostname.to_string(); + let rp_origin = Url::parse(&format!("https://{}", hostname)) + .map_err(|e| format!("Invalid origin URL: {}", e))?; + + let builder = WebauthnBuilder::new(&rp_id, &rp_origin) + .map_err(|e| format!("Failed to create WebAuthn builder: {}", e))? + .rp_name("Tranquil PDS") + .danger_set_user_presence_only_security_keys(true); + + let webauthn = builder + .build() + .map_err(|e| format!("Failed to build WebAuthn: {}", e))?; + + Ok(Self { webauthn }) + } + + pub fn start_registration( + &self, + user_id: &str, + username: &str, + display_name: &str, + exclude_credentials: Vec, + ) -> Result<(CreationChallengeResponse, SecurityKeyRegistration), String> { + let user_unique_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, user_id.as_bytes()); + + self.webauthn + .start_securitykey_registration( + user_unique_id, + username, + display_name, + if exclude_credentials.is_empty() { + None + } else { + Some(exclude_credentials) + }, + None, + None, + ) + .map_err(|e| format!("Failed to start registration: {}", e)) + } + + pub fn finish_registration( + &self, + reg: &RegisterPublicKeyCredential, + state: &SecurityKeyRegistration, + ) -> Result { + self.webauthn + .finish_securitykey_registration(reg, state) + .map_err(|e| format!("Failed to finish registration: {}", e)) + } + + pub fn start_authentication( + &self, + credentials: Vec, + ) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), String> { + self.webauthn + .start_securitykey_authentication(&credentials) + .map_err(|e| format!("Failed to start authentication: {}", e)) + } + + pub fn finish_authentication( + &self, + auth: &PublicKeyCredential, + state: &SecurityKeyAuthentication, + ) -> Result { + self.webauthn + .finish_securitykey_authentication(auth, state) + .map_err(|e| format!("Failed to finish authentication: {}", e)) + } +} + +pub async fn save_registration_state( + pool: &PgPool, + did: &str, + state: &SecurityKeyRegistration, +) -> Result { + let id = Uuid::new_v4(); + let state_json = serde_json::to_string(state) + .map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize state: {}", e)))?; + let challenge = id.as_bytes().to_vec(); + let expires_at = Utc::now() + Duration::minutes(5); + + sqlx::query!( + r#" + INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at) + VALUES ($1, $2, $3, 'registration', $4, $5) + "#, + id, + did, + challenge, + state_json, + expires_at, + ) + .execute(pool) + .await?; + + Ok(id) +} + +pub async fn load_registration_state( + pool: &PgPool, + did: &str, +) -> Result, sqlx::Error> { + let row = sqlx::query!( + r#" + SELECT state_json FROM webauthn_challenges + WHERE did = $1 AND challenge_type = 'registration' AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 1 + "#, + did, + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => { + let state: SecurityKeyRegistration = + serde_json::from_str(&r.state_json).map_err(|e| { + sqlx::Error::Protocol(format!("Failed to deserialize state: {}", e)) + })?; + Ok(Some(state)) + } + None => Ok(None), + } +} + +pub async fn delete_registration_state(pool: &PgPool, did: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'registration'", + did, + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn save_authentication_state( + pool: &PgPool, + did: &str, + state: &SecurityKeyAuthentication, +) -> Result { + let id = Uuid::new_v4(); + let state_json = serde_json::to_string(state) + .map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize state: {}", e)))?; + let challenge = id.as_bytes().to_vec(); + let expires_at = Utc::now() + Duration::minutes(5); + + sqlx::query!( + r#" + INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at) + VALUES ($1, $2, $3, 'authentication', $4, $5) + "#, + id, + did, + challenge, + state_json, + expires_at, + ) + .execute(pool) + .await?; + + Ok(id) +} + +pub async fn load_authentication_state( + pool: &PgPool, + did: &str, +) -> Result, sqlx::Error> { + let row = sqlx::query!( + r#" + SELECT state_json FROM webauthn_challenges + WHERE did = $1 AND challenge_type = 'authentication' AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 1 + "#, + did, + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => { + let state: SecurityKeyAuthentication = + serde_json::from_str(&r.state_json).map_err(|e| { + sqlx::Error::Protocol(format!("Failed to deserialize state: {}", e)) + })?; + Ok(Some(state)) + } + None => Ok(None), + } +} + +pub async fn delete_authentication_state(pool: &PgPool, did: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'authentication'", + did, + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn cleanup_expired_challenges(pool: &PgPool) -> Result { + let result = sqlx::query!("DELETE FROM webauthn_challenges WHERE expires_at < NOW()") + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + +#[derive(Debug, Clone)] +pub struct StoredPasskey { + pub id: Uuid, + pub did: String, + pub credential_id: Vec, + pub public_key: Vec, + pub sign_count: i32, + pub created_at: chrono::DateTime, + pub last_used: Option>, + pub friendly_name: Option, + pub aaguid: Option>, + pub transports: Option>, +} + +impl StoredPasskey { + pub fn to_security_key(&self) -> Result { + serde_json::from_slice(&self.public_key) + .map_err(|e| format!("Failed to deserialize security key: {}", e)) + } + + pub fn credential_id_base64(&self) -> String { + URL_SAFE_NO_PAD.encode(&self.credential_id) + } +} + +pub async fn save_passkey( + pool: &PgPool, + did: &str, + security_key: &SecurityKey, + friendly_name: Option<&str>, +) -> Result { + let id = Uuid::new_v4(); + let credential_id = security_key.cred_id().to_vec(); + let public_key = serde_json::to_vec(security_key) + .map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize security key: {}", e)))?; + let aaguid: Option> = None; + + sqlx::query!( + r#" + INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name, aaguid) + VALUES ($1, $2, $3, $4, 0, $5, $6) + "#, + id, + did, + credential_id, + public_key, + friendly_name, + aaguid, + ) + .execute(pool) + .await?; + + Ok(id) +} + +pub async fn get_passkeys_for_user( + pool: &PgPool, + did: &str, +) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#" + SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports + FROM passkeys + WHERE did = $1 + ORDER BY created_at DESC + "#, + did, + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| StoredPasskey { + id: r.id, + did: r.did, + credential_id: r.credential_id, + public_key: r.public_key, + sign_count: r.sign_count, + created_at: r.created_at, + last_used: r.last_used, + friendly_name: r.friendly_name, + aaguid: r.aaguid, + transports: r.transports, + }) + .collect()) +} + +pub async fn get_passkey_by_credential_id( + pool: &PgPool, + credential_id: &[u8], +) -> Result, sqlx::Error> { + let row = sqlx::query!( + r#" + SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports + FROM passkeys + WHERE credential_id = $1 + "#, + credential_id, + ) + .fetch_optional(pool) + .await?; + + Ok(row.map(|r| StoredPasskey { + id: r.id, + did: r.did, + credential_id: r.credential_id, + public_key: r.public_key, + sign_count: r.sign_count, + created_at: r.created_at, + last_used: r.last_used, + friendly_name: r.friendly_name, + aaguid: r.aaguid, + transports: r.transports, + })) +} + +pub async fn update_passkey_counter( + pool: &PgPool, + credential_id: &[u8], + new_counter: u32, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", + new_counter as i32, + credential_id, + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result { + let result = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND did = $2") + .bind(id) + .bind(did) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn update_passkey_name( + pool: &PgPool, + id: Uuid, + did: &str, + name: &str, +) -> Result { + let result = sqlx::query("UPDATE passkeys SET friendly_name = $1 WHERE id = $2 AND did = $3") + .bind(name) + .bind(id) + .bind(did) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn has_passkeys(pool: &PgPool, did: &str) -> Result { + let row = sqlx::query("SELECT COUNT(*) as count FROM passkeys WHERE did = $1") + .bind(did) + .fetch_one(pool) + .await?; + let count: i64 = row.get("count"); + Ok(count > 0) +} diff --git a/src/lib.rs b/src/lib.rs index 2a33529..6616a57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -278,6 +278,46 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.server.getAccountInviteCodes", get(api::server::get_account_invite_codes), ) + .route( + "/xrpc/com.atproto.server.createTotpSecret", + post(api::server::create_totp_secret), + ) + .route( + "/xrpc/com.atproto.server.enableTotp", + post(api::server::enable_totp), + ) + .route( + "/xrpc/com.atproto.server.disableTotp", + post(api::server::disable_totp), + ) + .route( + "/xrpc/com.atproto.server.getTotpStatus", + get(api::server::get_totp_status), + ) + .route( + "/xrpc/com.atproto.server.regenerateBackupCodes", + post(api::server::regenerate_backup_codes), + ) + .route( + "/xrpc/com.atproto.server.startPasskeyRegistration", + post(api::server::start_passkey_registration), + ) + .route( + "/xrpc/com.atproto.server.finishPasskeyRegistration", + post(api::server::finish_passkey_registration), + ) + .route( + "/xrpc/com.atproto.server.listPasskeys", + get(api::server::list_passkeys), + ) + .route( + "/xrpc/com.atproto.server.deletePasskey", + post(api::server::delete_passkey), + ) + .route( + "/xrpc/com.atproto.server.updatePasskey", + post(api::server::update_passkey), + ) .route( "/xrpc/com.atproto.admin.getInviteCodes", get(api::admin::get_invite_codes), @@ -359,6 +399,22 @@ pub fn app(state: AppState) -> Router { "/oauth/authorize/2fa", post(oauth::endpoints::authorize_2fa_post), ) + .route( + "/oauth/passkey/check", + get(oauth::endpoints::check_user_has_passkeys), + ) + .route( + "/oauth/security-status", + get(oauth::endpoints::check_user_security_status), + ) + .route( + "/oauth/passkey/start", + post(oauth::endpoints::passkey_start), + ) + .route( + "/oauth/passkey/finish", + post(oauth::endpoints::passkey_finish), + ) .route( "/oauth/authorize/deny", post(oauth::endpoints::authorize_deny), diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index f21c860..5a44a34 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -486,6 +486,25 @@ pub async fn authorize_post( if !password_valid { return show_login_error("Invalid handle/email or password.", json_response); } + let has_totp = crate::api::server::has_totp_enabled(&state, &user.did).await; + if has_totp { + if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None) + .await + .is_err() + { + return show_login_error("An error occurred. Please try again.", json_response); + } + if json_response { + return Json(serde_json::json!({ + "needs_totp": true + })) + .into_response(); + } + return redirect_see_other(&format!( + "/#/oauth/totp?request_uri={}", + url_encode(&form.request_uri) + )); + } if user.two_factor_enabled { let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; match db::create_2fa_challenge(&state.db, &user.did, &form.request_uri).await { @@ -745,6 +764,23 @@ pub async fn authorize_select( "Please verify your account before logging in.", ); } + let has_totp = crate::api::server::has_totp_enabled(&state, &form.did).await; + if has_totp { + if db::set_authorization_did(&state.db, &form.request_uri, &form.did, Some(&device_id)) + .await + .is_err() + { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "An error occurred. Please try again.", + ); + } + return Json(serde_json::json!({ + "needs_totp": true + })) + .into_response(); + } if user.two_factor_enabled { let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; match db::create_2fa_challenge(&state.db, &form.did, &form.request_uri).await { @@ -1323,54 +1359,6 @@ pub async fn authorize_2fa_post( "Too many attempts. Please try again later.", ); } - let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await { - Ok(Some(c)) => c, - Ok(None) => { - return json_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "No 2FA challenge found. Please start over.", - ); - } - Err(_) => { - return json_error( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "An error occurred. Please try again.", - ); - } - }; - if challenge.expires_at < Utc::now() { - let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; - return json_error( - StatusCode::BAD_REQUEST, - "invalid_request", - "2FA code has expired. Please start over.", - ); - } - if challenge.attempts >= MAX_2FA_ATTEMPTS { - let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; - return json_error( - StatusCode::FORBIDDEN, - "access_denied", - "Too many failed attempts. Please start over.", - ); - } - let code_valid: bool = form - .code - .trim() - .as_bytes() - .ct_eq(challenge.code.as_bytes()) - .into(); - if !code_valid { - let _ = db::increment_2fa_attempts(&state.db, challenge.id).await; - return json_error( - StatusCode::FORBIDDEN, - "invalid_code", - "Invalid verification code. Please try again.", - ); - } - let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { Ok(Some(d)) => d, Ok(None) => { @@ -1388,12 +1376,135 @@ pub async fn authorize_2fa_post( ); } }; + if request_data.expires_at < Utc::now() { + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "Authorization request has expired.", + ); + } + let challenge = db::get_2fa_challenge(&state.db, &form.request_uri) + .await + .ok() + .flatten(); + if let Some(challenge) = challenge { + if challenge.expires_at < Utc::now() { + let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "2FA code has expired. Please start over.", + ); + } + if challenge.attempts >= MAX_2FA_ATTEMPTS { + let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; + return json_error( + StatusCode::FORBIDDEN, + "access_denied", + "Too many failed attempts. Please start over.", + ); + } + let code_valid: bool = form + .code + .trim() + .as_bytes() + .ct_eq(challenge.code.as_bytes()) + .into(); + if !code_valid { + let _ = db::increment_2fa_attempts(&state.db, challenge.id).await; + return json_error( + StatusCode::FORBIDDEN, + "invalid_code", + "Invalid verification code. Please try again.", + ); + } + let _ = db::delete_2fa_challenge(&state.db, challenge.id).await; + let code = Code::generate(); + let device_id = extract_device_cookie(&headers); + if db::update_authorization_request( + &state.db, + &form.request_uri, + &challenge.did, + device_id.as_deref(), + &code.0, + ) + .await + .is_err() + { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "An error occurred. Please try again.", + ); + } + let redirect_url = build_success_redirect( + &request_data.parameters.redirect_uri, + &code.0, + request_data.parameters.state.as_deref(), + request_data.parameters.response_mode.as_deref(), + ); + return Json(serde_json::json!({ + "redirect_uri": redirect_url + })) + .into_response(); + } + let did = match &request_data.did { + Some(d) => d.clone(), + None => { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "No 2FA challenge found. Please start over.", + ); + } + }; + if !crate::api::server::has_totp_enabled(&state, &did).await { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "No 2FA challenge found. Please start over.", + ); + } + let totp_valid = + crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await; + if !totp_valid { + return json_error( + StatusCode::FORBIDDEN, + "invalid_code", + "Invalid verification code. Please try again.", + ); + } + let requested_scope_str = request_data + .parameters + .scope + .as_deref() + .unwrap_or("atproto"); + let requested_scopes: Vec = requested_scope_str + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + let needs_consent = db::should_show_consent( + &state.db, + &did, + &request_data.parameters.client_id, + &requested_scopes, + ) + .await + .unwrap_or(true); + if needs_consent { + let consent_url = format!( + "/#/oauth/consent?request_uri={}", + url_encode(&form.request_uri) + ); + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); + } let code = Code::generate(); let device_id = extract_device_cookie(&headers); if db::update_authorization_request( &state.db, &form.request_uri, - &challenge.did, + &did, device_id.as_deref(), &code.0, ) @@ -1417,3 +1528,616 @@ pub async fn authorize_2fa_post( })) .into_response() } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckPasskeysQuery { + pub identifier: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckPasskeysResponse { + pub has_passkeys: bool, +} + +pub async fn check_user_has_passkeys( + State(state): State, + Query(query): Query, +) -> Response { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let normalized_identifier = query.identifier.trim(); + let normalized_identifier = normalized_identifier + .strip_prefix('@') + .unwrap_or(normalized_identifier); + let normalized_identifier = if let Some(bare_handle) = + normalized_identifier.strip_suffix(&format!(".{}", pds_hostname)) + { + bare_handle.to_string() + } else { + normalized_identifier.to_string() + }; + + let user = sqlx::query!( + "SELECT did FROM users WHERE handle = $1 OR email = $1", + normalized_identifier + ) + .fetch_optional(&state.db) + .await; + + let has_passkeys = match user { + Ok(Some(u)) => crate::api::server::has_passkeys_for_user(&state, &u.did).await, + _ => false, + }; + + Json(CheckPasskeysResponse { has_passkeys }).into_response() +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SecurityStatusResponse { + pub has_passkeys: bool, + pub has_totp: bool, +} + +pub async fn check_user_security_status( + State(state): State, + Query(query): Query, +) -> Response { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let normalized_identifier = query.identifier.trim(); + let normalized_identifier = normalized_identifier + .strip_prefix('@') + .unwrap_or(normalized_identifier); + let normalized_identifier = if let Some(bare_handle) = + normalized_identifier.strip_suffix(&format!(".{}", pds_hostname)) + { + bare_handle.to_string() + } else { + normalized_identifier.to_string() + }; + + let user = sqlx::query!( + "SELECT did FROM users WHERE handle = $1 OR email = $1", + normalized_identifier + ) + .fetch_optional(&state.db) + .await; + + let (has_passkeys, has_totp) = match user { + Ok(Some(u)) => { + let passkeys = crate::api::server::has_passkeys_for_user(&state, &u.did).await; + let totp = crate::api::server::has_totp_enabled(&state, &u.did).await; + (passkeys, totp) + } + _ => (false, false), + }; + + Json(SecurityStatusResponse { + has_passkeys, + has_totp, + }) + .into_response() +} + +#[derive(Debug, Deserialize)] +pub struct PasskeyStartInput { + pub request_uri: String, + pub identifier: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyStartResponse { + pub options: serde_json::Value, +} + +pub async fn passkey_start( + State(state): State, + headers: HeaderMap, + Json(form): Json, +) -> Response { + let client_ip = extract_client_ip(&headers); + + if !state + .check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip) + .await + { + tracing::warn!(ip = %client_ip, "OAuth passkey rate limit exceeded"); + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ + "error": "RateLimitExceeded", + "error_description": "Too many login attempts. Please try again later." + })), + ) + .into_response(); + } + + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Invalid or expired request_uri." + })), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + }; + + if request_data.expires_at < Utc::now() { + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Authorization request has expired." + })), + ) + .into_response(); + } + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let normalized_username = form.identifier.trim(); + let normalized_username = normalized_username + .strip_prefix('@') + .unwrap_or(normalized_username); + let normalized_username = if let Some(bare_handle) = + normalized_username.strip_suffix(&format!(".{}", pds_hostname)) + { + bare_handle.to_string() + } else { + normalized_username.to_string() + }; + + let user = match sqlx::query!( + r#" + SELECT did, deactivated_at, takedown_ref, + email_verified, discord_verified, telegram_verified, signal_verified + FROM users + WHERE handle = $1 OR email = $1 + "#, + normalized_username + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(u)) => u, + Ok(None) => { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "User not found or has no passkeys." + })), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + }; + + if user.deactivated_at.is_some() { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "This account has been deactivated." + })), + ) + .into_response(); + } + + if user.takedown_ref.is_some() { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "This account has been taken down." + })), + ) + .into_response(); + } + + let is_verified = user.email_verified + || user.discord_verified + || user.telegram_verified + || user.signal_verified; + + if !is_verified { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Please verify your account before logging in." + })), + ) + .into_response(); + } + + let stored_passkeys = + match crate::auth::webauthn::get_passkeys_for_user(&state.db, &user.did).await { + Ok(pks) => pks, + Err(e) => { + tracing::error!(error = %e, "Failed to get passkeys"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + }; + + if stored_passkeys.is_empty() { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "User not found or has no passkeys." + })), + ) + .into_response(); + } + + let passkeys: Vec = stored_passkeys + .iter() + .filter_map(|sp| sp.to_security_key().ok()) + .collect(); + + if passkeys.is_empty() { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "Failed to load passkeys." + })), + ) + .into_response(); + } + + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { + Ok(w) => w, + Err(e) => { + tracing::error!(error = %e, "Failed to create WebAuthn config"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "WebAuthn configuration failed." + })), + ) + .into_response(); + } + }; + + let (rcr, auth_state) = match webauthn.start_authentication(passkeys) { + Ok(result) => result, + Err(e) => { + tracing::error!(error = %e, "Failed to start passkey authentication"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "Failed to start authentication." + })), + ) + .into_response(); + } + }; + + if let Err(e) = + crate::auth::webauthn::save_authentication_state(&state.db, &user.did, &auth_state).await + { + tracing::error!(error = %e, "Failed to save authentication state"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + + if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None) + .await + .is_err() + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + + let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); + + Json(PasskeyStartResponse { options }).into_response() +} + +#[derive(Debug, Deserialize)] +pub struct PasskeyFinishInput { + pub request_uri: String, + pub credential: serde_json::Value, +} + +pub async fn passkey_finish( + State(state): State, + headers: HeaderMap, + Json(form): Json, +) -> Response { + let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Invalid or expired request_uri." + })), + ) + .into_response(); + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + }; + + if request_data.expires_at < Utc::now() { + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Authorization request has expired." + })), + ) + .into_response(); + } + + let did = match request_data.did { + Some(d) => d, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "No passkey authentication in progress." + })), + ) + .into_response(); + } + }; + + let auth_state = match crate::auth::webauthn::load_authentication_state(&state.db, &did).await { + Ok(Some(s)) => s, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "No passkey authentication in progress or challenge expired." + })), + ) + .into_response(); + } + Err(e) => { + tracing::error!(error = %e, "Failed to load authentication state"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + }; + + let credential: webauthn_rs::prelude::PublicKeyCredential = + match serde_json::from_value(form.credential) { + Ok(c) => c, + Err(e) => { + tracing::warn!(error = %e, "Failed to parse credential"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_request", + "error_description": "Failed to parse credential response." + })), + ) + .into_response(); + } + }; + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { + Ok(w) => w, + Err(e) => { + tracing::error!(error = %e, "Failed to create WebAuthn config"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "WebAuthn configuration failed." + })), + ) + .into_response(); + } + }; + + let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, did = %did, "Failed to verify passkey authentication"); + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Passkey verification failed." + })), + ) + .into_response(); + } + }; + + if let Err(e) = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await { + tracing::warn!(error = %e, "Failed to delete authentication state"); + } + + if auth_result.needs_update() + && let Err(e) = crate::auth::webauthn::update_passkey_counter( + &state.db, + auth_result.cred_id(), + auth_result.counter(), + ) + .await + { + tracing::warn!(error = %e, "Failed to update passkey counter"); + } + + tracing::info!(did = %did, "Passkey authentication successful"); + + let has_totp = crate::api::server::has_totp_enabled(&state, &did).await; + if has_totp { + return Json(serde_json::json!({ + "needs_totp": true + })) + .into_response(); + } + + let user = sqlx::query!( + "SELECT two_factor_enabled, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", id FROM users WHERE did = $1", + did + ) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(user)) = user + && user.two_factor_enabled + { + let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await; + match db::create_2fa_challenge(&state.db, &did, &form.request_uri).await { + Ok(challenge) => { + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + if let Err(e) = + enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await + { + tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification"); + } + let channel_name = channel_display_name(user.preferred_comms_channel); + return Json(serde_json::json!({ + "needs_2fa": true, + "channel": channel_name + })) + .into_response(); + } + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + } + } + + let device_id = extract_device_cookie(&headers); + let requested_scope_str = request_data + .parameters + .scope + .as_deref() + .unwrap_or("atproto"); + let requested_scopes: Vec = requested_scope_str + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + + let needs_consent = db::should_show_consent( + &state.db, + &did, + &request_data.parameters.client_id, + &requested_scopes, + ) + .await + .unwrap_or(true); + + if needs_consent { + let consent_url = format!( + "/#/oauth/consent?request_uri={}", + url_encode(&form.request_uri) + ); + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); + } + + let code = Code::generate(); + if db::update_authorization_request( + &state.db, + &form.request_uri, + &did, + device_id.as_deref(), + &code.0, + ) + .await + .is_err() + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "An error occurred." + })), + ) + .into_response(); + } + + let redirect_url = build_success_redirect( + &request_data.parameters.redirect_uri, + &code.0, + request_data.parameters.state.as_deref(), + request_data.parameters.response_mode.as_deref(), + ); + + Json(serde_json::json!({ + "redirect_uri": redirect_url + })) + .into_response() +}