mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-03 08:46:55 +00:00
Security key & totp support
This commit is contained in:
+22
@@ -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"
|
||||
}
|
||||
+76
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM webauthn_challenges WHERE expires_at < NOW()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM user_totp WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+46
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM backup_codes WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+76
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+58
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
Generated
+227
@@ -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"
|
||||
|
||||
+4
-1
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
await xrpc('com.atproto.server.deletePasskey', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { id },
|
||||
})
|
||||
},
|
||||
|
||||
async updatePasskey(token: string, id: string, friendlyName: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.updatePasskey', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { id, friendlyName },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -155,6 +155,10 @@
|
||||
<h3>Account Settings</h3>
|
||||
<p>Email, password, handle, and more</p>
|
||||
</a>
|
||||
<a href="#/security" class="nav-card">
|
||||
<h3>Security</h3>
|
||||
<p>Two-factor authentication</p>
|
||||
</a>
|
||||
<a href="#/notifications" class="nav-card">
|
||||
<h3>Notification Preferences</h3>
|
||||
<p>Discord, Telegram, Signal channels</p>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
let rememberDevice = $state(false)
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let hasPasskeys = $state(false)
|
||||
let hasTotp = $state(false)
|
||||
let checkingSecurityStatus = $state(false)
|
||||
let securityStatusChecked = $state(false)
|
||||
let passkeySupported = $state(false)
|
||||
let clientName = $state<string | null>(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<typeof setTimeout> | 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 @@
|
||||
|
||||
<div class="oauth-login-container">
|
||||
<h1>Sign In</h1>
|
||||
<p class="subtitle">Sign in to continue to the application</p>
|
||||
<p class="subtitle">
|
||||
{#if clientName}
|
||||
Sign in to continue to <strong>{clientName}</strong>
|
||||
{:else}
|
||||
Sign in to continue to the application
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
@@ -126,6 +341,36 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if securityStatusChecked && passkeySupported}
|
||||
<button
|
||||
type="button"
|
||||
class="passkey-btn"
|
||||
class:passkey-unavailable={!hasPasskeys}
|
||||
onclick={handlePasskeyLogin}
|
||||
disabled={submitting || !hasPasskeys || !username}
|
||||
title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'}
|
||||
>
|
||||
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
|
||||
<path d="M17 17v4l3-2-3-2z" />
|
||||
<path d="M12 11c-4 0-6 2-6 4v4h9" />
|
||||
</svg>
|
||||
<span class="passkey-text">
|
||||
{#if submitting}
|
||||
Authenticating...
|
||||
{:else if hasPasskeys}
|
||||
Sign in with passkey
|
||||
{:else}
|
||||
Passkey not set up
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>or use password</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
@@ -266,4 +511,66 @@
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color-light);
|
||||
}
|
||||
|
||||
.auth-divider span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.passkey-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.passkey-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.passkey-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.passkey-btn.passkey-unavailable {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.passkey-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.passkey-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
|
||||
let code = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri) {
|
||||
error = 'Missing request_uri parameter'
|
||||
return
|
||||
}
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/oauth/authorize/2fa', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
code: code.trim().toUpperCase()
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
error = data.error_description || data.error || 'Verification failed'
|
||||
submitting = false
|
||||
return
|
||||
}
|
||||
|
||||
if (data.redirect_uri) {
|
||||
window.location.href = data.redirect_uri
|
||||
return
|
||||
}
|
||||
|
||||
error = 'Unexpected response from server'
|
||||
submitting = false
|
||||
} catch {
|
||||
error = 'Failed to connect to server'
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
const requestUri = getRequestUri()
|
||||
if (requestUri) {
|
||||
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
|
||||
} else {
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
let isBackupCode = $derived(code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim()))
|
||||
let isTotpCode = $derived(code.trim().length === 6 && /^[0-9]+$/.test(code.trim()))
|
||||
let canSubmit = $derived(isBackupCode || isTotpCode)
|
||||
</script>
|
||||
|
||||
<div class="oauth-totp-container">
|
||||
<h1>Two-Factor Authentication</h1>
|
||||
<p class="subtitle">
|
||||
Enter the 6-digit code from your authenticator app, or use a backup code.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="field">
|
||||
<label for="code">Verification Code</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
bind:value={code}
|
||||
placeholder="Enter code"
|
||||
disabled={submitting}
|
||||
required
|
||||
maxlength="8"
|
||||
autocomplete="one-time-code"
|
||||
autocapitalize="characters"
|
||||
/>
|
||||
<p class="hint">
|
||||
{#if isBackupCode}
|
||||
Using backup code
|
||||
{:else if isTotpCode}
|
||||
Using authenticator code
|
||||
{:else}
|
||||
6 digits for authenticator, 8 characters for backup code
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !canSubmit}>
|
||||
{submitting ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.oauth-totp-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-align: center;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.25rem 0 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cancel-btn:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,897 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
const auth = getAuthState()
|
||||
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
let loading = $state(true)
|
||||
let totpEnabled = $state(false)
|
||||
let hasBackupCodes = $state(false)
|
||||
let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle')
|
||||
let qrBase64 = $state('')
|
||||
let totpUri = $state('')
|
||||
let verifyCodeRaw = $state('')
|
||||
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
|
||||
let verifyLoading = $state(false)
|
||||
let backupCodes = $state<string[]>([])
|
||||
let disablePassword = $state('')
|
||||
let disableCode = $state('')
|
||||
let disableLoading = $state(false)
|
||||
let showDisableForm = $state(false)
|
||||
let regenPassword = $state('')
|
||||
let regenCode = $state('')
|
||||
let regenLoading = $state(false)
|
||||
let showRegenForm = $state(false)
|
||||
|
||||
interface Passkey {
|
||||
id: string
|
||||
credentialId: string
|
||||
friendlyName: string | null
|
||||
createdAt: string
|
||||
lastUsed: string | null
|
||||
}
|
||||
let passkeys = $state<Passkey[]>([])
|
||||
let passkeysLoading = $state(true)
|
||||
let addingPasskey = $state(false)
|
||||
let newPasskeyName = $state('')
|
||||
let editingPasskeyId = $state<string | null>(null)
|
||||
let editPasskeyName = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadTotpStatus()
|
||||
loadPasskeys()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadTotpStatus() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
try {
|
||||
const status = await api.getTotpStatus(auth.session.accessJwt)
|
||||
totpEnabled = status.enabled
|
||||
hasBackupCodes = status.hasBackupCodes
|
||||
} catch {
|
||||
showMessage('error', 'Failed to load TOTP status')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(type: 'success' | 'error', text: string) {
|
||||
message = { type, text }
|
||||
setTimeout(() => {
|
||||
if (message?.text === text) message = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function handleStartSetup() {
|
||||
if (!auth.session) return
|
||||
verifyLoading = true
|
||||
try {
|
||||
const result = await api.createTotpSecret(auth.session.accessJwt)
|
||||
qrBase64 = result.qrBase64
|
||||
totpUri = result.uri
|
||||
setupStep = 'qr'
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
|
||||
} finally {
|
||||
verifyLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerifySetup(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !verifyCode) return
|
||||
verifyLoading = true
|
||||
try {
|
||||
const result = await api.enableTotp(auth.session.accessJwt, verifyCode)
|
||||
backupCodes = result.backupCodes
|
||||
setupStep = 'backup'
|
||||
totpEnabled = true
|
||||
hasBackupCodes = true
|
||||
verifyCodeRaw = ''
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
|
||||
} finally {
|
||||
verifyLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFinishSetup() {
|
||||
setupStep = 'idle'
|
||||
backupCodes = []
|
||||
qrBase64 = ''
|
||||
totpUri = ''
|
||||
showMessage('success', 'Two-factor authentication enabled successfully')
|
||||
}
|
||||
|
||||
async function handleDisable(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !disablePassword || !disableCode) return
|
||||
disableLoading = true
|
||||
try {
|
||||
await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode)
|
||||
totpEnabled = false
|
||||
hasBackupCodes = false
|
||||
showDisableForm = false
|
||||
disablePassword = ''
|
||||
disableCode = ''
|
||||
showMessage('success', 'Two-factor authentication disabled')
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP')
|
||||
} finally {
|
||||
disableLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerate(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !regenPassword || !regenCode) return
|
||||
regenLoading = true
|
||||
try {
|
||||
const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode)
|
||||
backupCodes = result.backupCodes
|
||||
setupStep = 'backup'
|
||||
showRegenForm = false
|
||||
regenPassword = ''
|
||||
regenCode = ''
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
|
||||
} finally {
|
||||
regenLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyBackupCodes() {
|
||||
const text = backupCodes.join('\n')
|
||||
navigator.clipboard.writeText(text)
|
||||
showMessage('success', 'Backup codes copied to clipboard')
|
||||
}
|
||||
|
||||
async function loadPasskeys() {
|
||||
if (!auth.session) return
|
||||
passkeysLoading = true
|
||||
try {
|
||||
const result = await api.listPasskeys(auth.session.accessJwt)
|
||||
passkeys = result.passkeys
|
||||
} catch {
|
||||
showMessage('error', 'Failed to load passkeys')
|
||||
} finally {
|
||||
passkeysLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddPasskey() {
|
||||
if (!auth.session) return
|
||||
if (!window.PublicKeyCredential) {
|
||||
showMessage('error', 'Passkeys are not supported in this browser')
|
||||
return
|
||||
}
|
||||
addingPasskey = true
|
||||
try {
|
||||
const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined)
|
||||
const publicKeyOptions = preparePublicKeyOptions(options)
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions
|
||||
})
|
||||
if (!credential) {
|
||||
showMessage('error', 'Passkey creation was cancelled')
|
||||
return
|
||||
}
|
||||
const credentialResponse = {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: arrayBufferToBase64Url((credential as PublicKeyCredential).rawId),
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64Url((credential as PublicKeyCredential).response.clientDataJSON),
|
||||
attestationObject: arrayBufferToBase64Url(((credential as PublicKeyCredential).response as AuthenticatorAttestationResponse).attestationObject),
|
||||
},
|
||||
}
|
||||
await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined)
|
||||
await loadPasskeys()
|
||||
newPasskeyName = ''
|
||||
showMessage('success', 'Passkey added successfully')
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'NotAllowedError') {
|
||||
showMessage('error', 'Passkey creation was cancelled')
|
||||
} else {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey')
|
||||
}
|
||||
} finally {
|
||||
addingPasskey = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePasskey(id: string) {
|
||||
if (!auth.session) return
|
||||
if (!confirm('Are you sure you want to delete this passkey?')) return
|
||||
try {
|
||||
await api.deletePasskey(auth.session.accessJwt, id)
|
||||
await loadPasskeys()
|
||||
showMessage('success', 'Passkey deleted')
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePasskeyName() {
|
||||
if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return
|
||||
try {
|
||||
await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim())
|
||||
await loadPasskeys()
|
||||
editingPasskeyId = null
|
||||
editPasskeyName = ''
|
||||
showMessage('success', 'Passkey renamed')
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey')
|
||||
}
|
||||
}
|
||||
|
||||
function startEditPasskey(passkey: Passkey) {
|
||||
editingPasskeyId = passkey.id
|
||||
editPasskeyName = passkey.friendlyName || ''
|
||||
}
|
||||
|
||||
function cancelEditPasskey() {
|
||||
editingPasskeyId = null
|
||||
editPasskeyName = ''
|
||||
}
|
||||
|
||||
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 preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions {
|
||||
return {
|
||||
...options.publicKey,
|
||||
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
|
||||
user: {
|
||||
...options.publicKey.user,
|
||||
id: base64UrlToArrayBuffer(options.publicKey.user.id)
|
||||
},
|
||||
excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64UrlToArrayBuffer(cred.id)
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">← Dashboard</a>
|
||||
<h1>Security Settings</h1>
|
||||
</header>
|
||||
|
||||
{#if message}
|
||||
<div class="message {message.type}">{message.text}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else}
|
||||
<section>
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
<p class="description">
|
||||
Add an extra layer of security to your account using an authenticator app like Google Authenticator, Authy, or 1Password.
|
||||
</p>
|
||||
|
||||
{#if setupStep === 'idle'}
|
||||
{#if totpEnabled}
|
||||
<div class="status enabled">
|
||||
<span>Two-factor authentication is <strong>enabled</strong></span>
|
||||
</div>
|
||||
|
||||
{#if !showDisableForm && !showRegenForm}
|
||||
<div class="totp-actions">
|
||||
<button type="button" class="secondary" onclick={() => showRegenForm = true}>
|
||||
Regenerate Backup Codes
|
||||
</button>
|
||||
<button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRegenForm}
|
||||
<form onsubmit={handleRegenerate} class="inline-form">
|
||||
<h3>Regenerate Backup Codes</h3>
|
||||
<p class="warning-text">This will invalidate all existing backup codes.</p>
|
||||
<div class="field">
|
||||
<label for="regen-password">Password</label>
|
||||
<input
|
||||
id="regen-password"
|
||||
type="password"
|
||||
bind:value={regenPassword}
|
||||
placeholder="Enter your password"
|
||||
disabled={regenLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="regen-code">Authenticator Code</label>
|
||||
<input
|
||||
id="regen-code"
|
||||
type="text"
|
||||
bind:value={regenCode}
|
||||
placeholder="6-digit code"
|
||||
disabled={regenLoading}
|
||||
required
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
|
||||
{regenLoading ? 'Regenerating...' : 'Regenerate'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if showDisableForm}
|
||||
<form onsubmit={handleDisable} class="inline-form danger-form">
|
||||
<h3>Disable Two-Factor Authentication</h3>
|
||||
<p class="warning-text">This will make your account less secure.</p>
|
||||
<div class="field">
|
||||
<label for="disable-password">Password</label>
|
||||
<input
|
||||
id="disable-password"
|
||||
type="password"
|
||||
bind:value={disablePassword}
|
||||
placeholder="Enter your password"
|
||||
disabled={disableLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="disable-code">Authenticator Code</label>
|
||||
<input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
bind:value={disableCode}
|
||||
placeholder="6-digit code"
|
||||
disabled={disableLoading}
|
||||
required
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
|
||||
{disableLoading ? 'Disabling...' : 'Disable 2FA'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="status disabled">
|
||||
<span>Two-factor authentication is <strong>not enabled</strong></span>
|
||||
</div>
|
||||
<button onclick={handleStartSetup} disabled={verifyLoading}>
|
||||
{verifyLoading ? 'Setting up...' : 'Set Up Two-Factor Authentication'}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if setupStep === 'qr'}
|
||||
<div class="setup-step">
|
||||
<h3>Step 1: Scan QR Code</h3>
|
||||
<p>Scan this QR code with your authenticator app:</p>
|
||||
<div class="qr-container">
|
||||
<img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" />
|
||||
</div>
|
||||
<details class="manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
|
||||
</details>
|
||||
<button onclick={() => setupStep = 'verify'}>
|
||||
Next: Verify Code
|
||||
</button>
|
||||
</div>
|
||||
{:else if setupStep === 'verify'}
|
||||
<div class="setup-step">
|
||||
<h3>Step 2: Verify Setup</h3>
|
||||
<p>Enter the 6-digit code from your authenticator app:</p>
|
||||
<form onsubmit={handleVerifySetup}>
|
||||
<div class="field">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={verifyCodeRaw}
|
||||
placeholder="000000"
|
||||
disabled={verifyLoading}
|
||||
inputmode="numeric"
|
||||
class="code-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}>
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>
|
||||
{verifyLoading ? 'Verifying...' : 'Verify & Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{:else if setupStep === 'backup'}
|
||||
<div class="setup-step">
|
||||
<h3>Step 3: Save Backup Codes</h3>
|
||||
<p class="warning-text">
|
||||
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.
|
||||
</p>
|
||||
<div class="backup-codes">
|
||||
{#each backupCodes as code}
|
||||
<code class="backup-code">{code}</code>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" onclick={copyBackupCodes}>
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
<button onclick={handleFinishSetup}>
|
||||
I've Saved My Codes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Passkeys</h2>
|
||||
<p class="description">
|
||||
Passkeys are a secure, passwordless way to sign in using biometrics (fingerprint or face), a security key, or your device's screen lock.
|
||||
</p>
|
||||
|
||||
{#if passkeysLoading}
|
||||
<div class="loading">Loading passkeys...</div>
|
||||
{:else}
|
||||
{#if passkeys.length > 0}
|
||||
<div class="passkey-list">
|
||||
{#each passkeys as passkey}
|
||||
<div class="passkey-item">
|
||||
{#if editingPasskeyId === passkey.id}
|
||||
<div class="passkey-edit">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editPasskeyName}
|
||||
placeholder="Passkey name"
|
||||
class="passkey-name-input"
|
||||
/>
|
||||
<div class="passkey-edit-actions">
|
||||
<button type="button" class="small" onclick={handleSavePasskeyName}>Save</button>
|
||||
<button type="button" class="small secondary" onclick={cancelEditPasskey}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="passkey-info">
|
||||
<span class="passkey-name">{passkey.friendlyName || 'Unnamed passkey'}</span>
|
||||
<span class="passkey-meta">
|
||||
Added {formatDate(passkey.createdAt)}
|
||||
{#if passkey.lastUsed}
|
||||
· Last used {formatDate(passkey.lastUsed)}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="passkey-actions">
|
||||
<button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}>
|
||||
Rename
|
||||
</button>
|
||||
<button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="status disabled">
|
||||
<span>No passkeys registered</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="add-passkey">
|
||||
<div class="field">
|
||||
<label for="passkey-name">Passkey Name (optional)</label>
|
||||
<input
|
||||
id="passkey-name"
|
||||
type="text"
|
||||
bind:value={newPasskeyName}
|
||||
placeholder="e.g., MacBook Touch ID"
|
||||
disabled={addingPasskey}
|
||||
/>
|
||||
</div>
|
||||
<button onclick={handleAddPasskey} disabled={addingPasskey}>
|
||||
{addingPasskey ? 'Adding Passkey...' : 'Add a Passkey'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status.enabled {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status.disabled {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.totp-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.code-input {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5em;
|
||||
text-align: center;
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color-light);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--error-text);
|
||||
}
|
||||
|
||||
button.danger:hover:not(:disabled) {
|
||||
background: #900;
|
||||
}
|
||||
|
||||
button.danger-outline {
|
||||
background: transparent;
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
button.danger-outline:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.inline-form h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.danger-form {
|
||||
border-color: var(--error-border);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--error-text);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.setup-step {
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.setup-step h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.setup-step p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.manual-entry {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.manual-entry summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.secret-code {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.backup-codes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.backup-code {
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.passkey-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.passkey-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 6px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.passkey-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.passkey-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.passkey-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.passkey-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.passkey-edit {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.passkey-name-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.passkey-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
button.small {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.add-passkey {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color-light);
|
||||
}
|
||||
|
||||
.add-passkey .field {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<WebAuthnConfig, (StatusCode, Json<serde_json::Value>)> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartRegistrationResponse {
|
||||
pub options: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn start_passkey_registration(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<StartRegistrationInput>,
|
||||
) -> 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<CredentialID> = 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<String>,
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<FinishRegistrationInput>,
|
||||
) -> 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<String>,
|
||||
pub created_at: String,
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListPasskeysResponse {
|
||||
pub passkeys: Vec<PasskeyInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_passkeys(State(state): State<AppState>, 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<PasskeyInfo> = 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<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<DeletePasskeyInput>,
|
||||
) -> 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<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<UpdatePasskeyInput>,
|
||||
) -> 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)
|
||||
}
|
||||
@@ -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<AppState>, 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<String>,
|
||||
}
|
||||
|
||||
pub async fn enable_totp(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<EnableTotpInput>,
|
||||
) -> 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<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<DisableTotpInput>,
|
||||
) -> 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<AppState>, 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<String>,
|
||||
}
|
||||
|
||||
pub async fn regenerate_backup_codes(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<RegenerateBackupCodesInput>,
|
||||
) -> 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)))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u8> {
|
||||
let mut secret = vec![0u8; TOTP_SECRET_LENGTH];
|
||||
rand::thread_rng().fill_bytes(&mut secret);
|
||||
secret
|
||||
}
|
||||
|
||||
pub fn encrypt_totp_secret(secret: &[u8]) -> Result<Vec<u8>, String> {
|
||||
crate::config::encrypt_key(secret)
|
||||
}
|
||||
|
||||
pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result<Vec<u8>, String> {
|
||||
crate::config::decrypt_key(encrypted, Some(version))
|
||||
}
|
||||
|
||||
fn create_totp(
|
||||
secret: Vec<u8>,
|
||||
issuer: Option<String>,
|
||||
account_name: String,
|
||||
) -> Result<TOTP, String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Self, String> {
|
||||
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<CredentialID>,
|
||||
) -> 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<SecurityKey, String> {
|
||||
self.webauthn
|
||||
.finish_securitykey_registration(reg, state)
|
||||
.map_err(|e| format!("Failed to finish registration: {}", e))
|
||||
}
|
||||
|
||||
pub fn start_authentication(
|
||||
&self,
|
||||
credentials: Vec<SecurityKey>,
|
||||
) -> 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<AuthenticationResult, String> {
|
||||
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<Uuid, sqlx::Error> {
|
||||
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<Option<SecurityKeyRegistration>, 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<Uuid, sqlx::Error> {
|
||||
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<Option<SecurityKeyAuthentication>, 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<u64, sqlx::Error> {
|
||||
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<u8>,
|
||||
pub public_key: Vec<u8>,
|
||||
pub sign_count: i32,
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
pub last_used: Option<chrono::DateTime<Utc>>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub aaguid: Option<Vec<u8>>,
|
||||
pub transports: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl StoredPasskey {
|
||||
pub fn to_security_key(&self) -> Result<SecurityKey, String> {
|
||||
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<Uuid, sqlx::Error> {
|
||||
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<Vec<u8>> = 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<Vec<StoredPasskey>, 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<Option<StoredPasskey>, 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<bool, sqlx::Error> {
|
||||
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<bool, sqlx::Error> {
|
||||
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<bool, sqlx::Error> {
|
||||
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)
|
||||
}
|
||||
+56
@@ -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),
|
||||
|
||||
@@ -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<String> = 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<AppState>,
|
||||
Query(query): Query<CheckPasskeysQuery>,
|
||||
) -> 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<AppState>,
|
||||
Query(query): Query<CheckPasskeysQuery>,
|
||||
) -> 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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<PasskeyStartInput>,
|
||||
) -> 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<webauthn_rs::prelude::SecurityKey> = 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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<PasskeyFinishInput>,
|
||||
) -> 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<String> = 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user