mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-11 06:31:06 +00:00
Passkey-only accounts
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
[store]
|
||||
dir = "target/nextest"
|
||||
|
||||
[profile.default]
|
||||
retries = 0
|
||||
fail-fast = true
|
||||
test-threads = "num-cpus"
|
||||
|
||||
[profile.ci]
|
||||
retries = 2
|
||||
fail-fast = false
|
||||
test-threads = "num-cpus"
|
||||
|
||||
[test-groups]
|
||||
serial-env-tests = { max-threads = 1 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "test(/import_with_verification/) | test(/plc_migration/)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "test(/import_with_verification/) | test(/plc_migration/)"
|
||||
test-group = "serial-env-tests"
|
||||
|
||||
15
.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json
generated
Normal file
15
.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507"
|
||||
}
|
||||
14
.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json
generated
Normal file
14
.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80"
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification"
|
||||
"channel_verification",
|
||||
"passkey_recovery"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification"
|
||||
"channel_verification",
|
||||
"passkey_recovery"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
22
.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json
generated
Normal file
22
.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ap.password_hash FROM app_passwords ap\n JOIN users u ON ap.user_id = u.id\n WHERE u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2"
|
||||
}
|
||||
14
.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json
generated
Normal file
14
.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8"
|
||||
}
|
||||
16
.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json
generated
Normal file
16
.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74"
|
||||
}
|
||||
15
.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json
generated
Normal file
15
.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d"
|
||||
}
|
||||
@@ -38,7 +38,8 @@
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification"
|
||||
"channel_verification",
|
||||
"passkey_recovery"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, 'email', $2, $3, $4)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20"
|
||||
}
|
||||
23
.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json
generated
Normal file
23
.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "trusted_until",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65"
|
||||
}
|
||||
16
.sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json
generated
Normal file
16
.sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f"
|
||||
}
|
||||
30
.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json
generated
Normal file
30
.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483"
|
||||
}
|
||||
22
.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json
generated
Normal file
22
.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
|
||||
"query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -16,7 +16,20 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
"Uuid",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -24,5 +37,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32"
|
||||
"hash": "5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1"
|
||||
}
|
||||
22
.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json
generated
Normal file
22
.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has_password",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed"
|
||||
}
|
||||
23
.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json
generated
Normal file
23
.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT 1 as one FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE oad.did = $1 AND od.id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "one",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3"
|
||||
}
|
||||
22
.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json
generated
Normal file
22
.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "one",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034"
|
||||
}
|
||||
16
.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json
generated
Normal file
16
.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369"
|
||||
}
|
||||
40
.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json
generated
Normal file
40
.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "recovery_token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "recovery_token_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password_required",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b"
|
||||
}
|
||||
28
.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json
generated
Normal file
28
.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, password_hash FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3"
|
||||
}
|
||||
14
.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json
generated
Normal file
14
.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM passkeys WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM users WHERE LOWER(email) = $1",
|
||||
"query": "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,6 +11,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
@@ -18,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4"
|
||||
"hash": "7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317"
|
||||
}
|
||||
15
.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json
generated
Normal file
15
.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979"
|
||||
}
|
||||
41
.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json
generated
Normal file
41
.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json
generated
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password_required",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8"
|
||||
}
|
||||
22
.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json
generated
Normal file
22
.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "last_reauth_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5"
|
||||
}
|
||||
14
.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json
generated
Normal file
14
.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4"
|
||||
}
|
||||
40
.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
generated
Normal file
40
.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "recovery_token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "recovery_token_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848"
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745"
|
||||
|
||||
52
.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json
generated
Normal file
52
.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json
generated
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at\n FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()\n ORDER BY od.last_seen_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_agent",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "friendly_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "trusted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "trusted_until",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "last_seen_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f"
|
||||
}
|
||||
15
.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json
generated
Normal file
15
.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2",
|
||||
"query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b"
|
||||
"hash": "e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
|
||||
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n 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": [
|
||||
{
|
||||
@@ -25,11 +25,16 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "two_factor_enabled",
|
||||
"name": "password_required",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "two_factor_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "preferred_comms_channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
@@ -46,32 +51,32 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 7,
|
||||
"name": "deactivated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"name": "takedown_ref",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"ordinal": 10,
|
||||
"name": "discord_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"ordinal": 11,
|
||||
"name": "telegram_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"ordinal": 12,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -85,6 +90,7 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -96,5 +102,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0"
|
||||
"hash": "eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f"
|
||||
}
|
||||
46
.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json
generated
Normal file
46
.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json
generated
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "recovery_token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "recovery_token_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "password_required",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289"
|
||||
}
|
||||
22
.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json
generated
Normal file
22
.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has_pw",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba"
|
||||
}
|
||||
@@ -41,7 +41,8 @@
|
||||
"admin_email",
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification"
|
||||
"channel_verification",
|
||||
"passkey_recovery"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
40
.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json
generated
Normal file
40
.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\" FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "preferred_comms_channel: CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4"
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
import { initAuth, getAuthState } from './lib/auth.svelte'
|
||||
import Login from './routes/Login.svelte'
|
||||
import Register from './routes/Register.svelte'
|
||||
import RegisterPasskey from './routes/RegisterPasskey.svelte'
|
||||
import Verify from './routes/Verify.svelte'
|
||||
import ResetPassword from './routes/ResetPassword.svelte'
|
||||
import RecoverPasskey from './routes/RecoverPasskey.svelte'
|
||||
import RequestPasskeyRecovery from './routes/RequestPasskeyRecovery.svelte'
|
||||
import Dashboard from './routes/Dashboard.svelte'
|
||||
import AppPasswords from './routes/AppPasswords.svelte'
|
||||
import InviteCodes from './routes/InviteCodes.svelte'
|
||||
@@ -18,8 +21,10 @@
|
||||
import OAuthAccounts from './routes/OAuthAccounts.svelte'
|
||||
import OAuth2FA from './routes/OAuth2FA.svelte'
|
||||
import OAuthTotp from './routes/OAuthTotp.svelte'
|
||||
import OAuthPasskey from './routes/OAuthPasskey.svelte'
|
||||
import OAuthError from './routes/OAuthError.svelte'
|
||||
import Security from './routes/Security.svelte'
|
||||
import TrustedDevices from './routes/TrustedDevices.svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
@@ -33,10 +38,16 @@
|
||||
return Login
|
||||
case '/register':
|
||||
return Register
|
||||
case '/register-passkey':
|
||||
return RegisterPasskey
|
||||
case '/verify':
|
||||
return Verify
|
||||
case '/reset-password':
|
||||
return ResetPassword
|
||||
case '/recover-passkey':
|
||||
return RecoverPasskey
|
||||
case '/request-passkey-recovery':
|
||||
return RequestPasskeyRecovery
|
||||
case '/dashboard':
|
||||
return Dashboard
|
||||
case '/app-passwords':
|
||||
@@ -63,10 +74,14 @@
|
||||
return OAuth2FA
|
||||
case '/oauth/totp':
|
||||
return OAuthTotp
|
||||
case '/oauth/passkey':
|
||||
return OAuthPasskey
|
||||
case '/oauth/error':
|
||||
return OAuthError
|
||||
case '/security':
|
||||
return Security
|
||||
case '/trusted-devices':
|
||||
return TrustedDevices
|
||||
default:
|
||||
return auth.session ? Dashboard : Login
|
||||
}
|
||||
|
||||
430
frontend/src/components/ReauthModal.svelte
Normal file
430
frontend/src/components/ReauthModal.svelte
Normal file
@@ -0,0 +1,430 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
availableMethods?: string[]
|
||||
onSuccess: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
|
||||
|
||||
const auth = getAuthState()
|
||||
let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
|
||||
let password = $state('')
|
||||
let totpCode = $state('')
|
||||
let loading = $state(false)
|
||||
let error = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (show) {
|
||||
password = ''
|
||||
totpCode = ''
|
||||
error = ''
|
||||
if (availableMethods.includes('password')) {
|
||||
activeMethod = 'password'
|
||||
} else if (availableMethods.includes('totp')) {
|
||||
activeMethod = 'totp'
|
||||
} else if (availableMethods.includes('passkey')) {
|
||||
activeMethod = 'passkey'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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 prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
|
||||
return {
|
||||
...options.publicKey,
|
||||
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
|
||||
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64UrlToArrayBuffer(cred.id)
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !password) return
|
||||
loading = true
|
||||
error = ''
|
||||
try {
|
||||
await api.reauthPassword(auth.session.accessJwt, password)
|
||||
show = false
|
||||
onSuccess()
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Authentication failed'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotpSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !totpCode) return
|
||||
loading = true
|
||||
error = ''
|
||||
try {
|
||||
await api.reauthTotp(auth.session.accessJwt, totpCode)
|
||||
show = false
|
||||
onSuccess()
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Invalid code'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyAuth() {
|
||||
if (!auth.session) return
|
||||
if (!window.PublicKeyCredential) {
|
||||
error = 'Passkeys are not supported in this browser'
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
error = ''
|
||||
try {
|
||||
const { options } = await api.reauthPasskeyStart(auth.session.accessJwt)
|
||||
const publicKeyOptions = prepareAuthOptions(options)
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions
|
||||
})
|
||||
if (!credential) {
|
||||
error = 'Passkey authentication was cancelled'
|
||||
return
|
||||
}
|
||||
const pkCredential = credential as PublicKeyCredential
|
||||
const response = pkCredential.response as AuthenticatorAssertionResponse
|
||||
const credentialResponse = {
|
||||
id: pkCredential.id,
|
||||
type: pkCredential.type,
|
||||
rawId: arrayBufferToBase64Url(pkCredential.rawId),
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
|
||||
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
|
||||
signature: arrayBufferToBase64Url(response.signature),
|
||||
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
|
||||
},
|
||||
}
|
||||
await api.reauthPasskeyFinish(auth.session.accessJwt, credentialResponse)
|
||||
show = false
|
||||
onSuccess()
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'NotAllowedError') {
|
||||
error = 'Passkey authentication was cancelled'
|
||||
} else {
|
||||
error = e instanceof ApiError ? e.message : 'Passkey authentication failed'
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
show = false
|
||||
onCancel()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="modal-backdrop" onclick={handleClose} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2>Re-authentication Required</h2>
|
||||
<button class="close-btn" onclick={handleClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<p class="modal-description">
|
||||
This action requires you to verify your identity.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if availableMethods.length > 1}
|
||||
<div class="method-tabs">
|
||||
{#if availableMethods.includes('password')}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeMethod === 'password'}
|
||||
onclick={() => activeMethod = 'password'}
|
||||
>
|
||||
Password
|
||||
</button>
|
||||
{/if}
|
||||
{#if availableMethods.includes('totp')}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeMethod === 'totp'}
|
||||
onclick={() => activeMethod = 'totp'}
|
||||
>
|
||||
TOTP
|
||||
</button>
|
||||
{/if}
|
||||
{#if availableMethods.includes('passkey')}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeMethod === 'passkey'}
|
||||
onclick={() => activeMethod = 'passkey'}
|
||||
>
|
||||
Passkey
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-content">
|
||||
{#if activeMethod === 'password'}
|
||||
<form onsubmit={handlePasswordSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="reauth-password">Password</label>
|
||||
<input
|
||||
id="reauth-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={loading || !password}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
{:else if activeMethod === 'totp'}
|
||||
<form onsubmit={handleTotpSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="reauth-totp">Authenticator Code</label>
|
||||
<input
|
||||
id="reauth-totp"
|
||||
type="text"
|
||||
bind:value={totpCode}
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="6"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
{:else if activeMethod === 'passkey'}
|
||||
<div class="passkey-auth">
|
||||
<p>Click the button below to authenticate with your passkey.</p>
|
||||
<button
|
||||
class="btn-primary"
|
||||
onclick={handlePasskeyAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Authenticating...' : 'Use Passkey'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
padding: 1rem 1.5rem 0;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 1rem 1.5rem 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.method-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.passkey-auth {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.passkey-auth p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -2,10 +2,12 @@ const API_BASE = '/xrpc'
|
||||
|
||||
export class ApiError extends Error {
|
||||
public did?: string
|
||||
constructor(public status: number, public error: string, message: string, did?: string) {
|
||||
public reauthMethods?: string[]
|
||||
constructor(public status: number, public error: string, message: string, did?: string, reauthMethods?: string[]) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.did = did
|
||||
this.reauthMethods = reauthMethods
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +37,7 @@ async function xrpc<T>(method: string, options?: {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
|
||||
throw new ApiError(res.status, err.error, err.message, err.did)
|
||||
throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
@@ -208,10 +210,11 @@ export const api = {
|
||||
})
|
||||
},
|
||||
|
||||
async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
|
||||
async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> {
|
||||
return xrpc('com.atproto.server.requestEmailUpdate', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { email },
|
||||
})
|
||||
},
|
||||
|
||||
@@ -317,6 +320,17 @@ export const api = {
|
||||
})
|
||||
},
|
||||
|
||||
async removePassword(token: string): Promise<{ success: boolean }> {
|
||||
return xrpc('com.tranquil.account.removePassword', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
|
||||
return xrpc('com.tranquil.account.getPasswordStatus', { token })
|
||||
},
|
||||
|
||||
async listSessions(token: string): Promise<{
|
||||
sessions: Array<{
|
||||
id: string
|
||||
@@ -569,4 +583,128 @@ export const api = {
|
||||
body: { id, friendlyName },
|
||||
})
|
||||
},
|
||||
|
||||
async listTrustedDevices(token: string): Promise<{
|
||||
devices: Array<{
|
||||
id: string
|
||||
userAgent: string | null
|
||||
friendlyName: string | null
|
||||
trustedAt: string | null
|
||||
trustedUntil: string | null
|
||||
lastSeenAt: string
|
||||
}>
|
||||
}> {
|
||||
return xrpc('com.tranquil.account.listTrustedDevices', { token })
|
||||
},
|
||||
|
||||
async revokeTrustedDevice(token: string, deviceId: string): Promise<{ success: boolean }> {
|
||||
return xrpc('com.tranquil.account.revokeTrustedDevice', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { deviceId },
|
||||
})
|
||||
},
|
||||
|
||||
async updateTrustedDevice(token: string, deviceId: string, friendlyName: string): Promise<{ success: boolean }> {
|
||||
return xrpc('com.tranquil.account.updateTrustedDevice', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { deviceId, friendlyName },
|
||||
})
|
||||
},
|
||||
|
||||
async getReauthStatus(token: string): Promise<{
|
||||
requiresReauth: boolean
|
||||
lastReauthAt: string | null
|
||||
availableMethods: string[]
|
||||
}> {
|
||||
return xrpc('com.tranquil.account.getReauthStatus', { token })
|
||||
},
|
||||
|
||||
async reauthPassword(token: string, password: string): Promise<{ success: boolean; reauthAt: string }> {
|
||||
return xrpc('com.tranquil.account.reauthPassword', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { password },
|
||||
})
|
||||
},
|
||||
|
||||
async reauthTotp(token: string, code: string): Promise<{ success: boolean; reauthAt: string }> {
|
||||
return xrpc('com.tranquil.account.reauthTotp', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { code },
|
||||
})
|
||||
},
|
||||
|
||||
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
|
||||
return xrpc('com.tranquil.account.reauthPasskeyStart', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async reauthPasskeyFinish(token: string, credential: unknown): Promise<{ success: boolean; reauthAt: string }> {
|
||||
return xrpc('com.tranquil.account.reauthPasskeyFinish', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { credential },
|
||||
})
|
||||
},
|
||||
|
||||
async createPasskeyAccount(params: {
|
||||
handle: string
|
||||
email?: string
|
||||
inviteCode?: string
|
||||
didType?: DidType
|
||||
did?: string
|
||||
signingKey?: string
|
||||
verificationChannel?: VerificationChannel
|
||||
discordId?: string
|
||||
telegramUsername?: string
|
||||
signalNumber?: string
|
||||
}): Promise<{
|
||||
did: string
|
||||
handle: string
|
||||
setupToken: string
|
||||
setupExpiresAt: string
|
||||
}> {
|
||||
return xrpc('com.tranquil.account.createPasskeyAccount', {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
})
|
||||
},
|
||||
|
||||
async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
|
||||
return xrpc('com.tranquil.account.startPasskeyRegistrationForSetup', {
|
||||
method: 'POST',
|
||||
body: { did, setupToken, friendlyName },
|
||||
})
|
||||
},
|
||||
|
||||
async completePasskeySetup(did: string, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string): Promise<{
|
||||
did: string
|
||||
handle: string
|
||||
appPassword: string
|
||||
appPasswordName: string
|
||||
}> {
|
||||
return xrpc('com.tranquil.account.completePasskeySetup', {
|
||||
method: 'POST',
|
||||
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
|
||||
})
|
||||
},
|
||||
|
||||
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
|
||||
return xrpc('com.tranquil.account.requestPasskeyRecovery', {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
})
|
||||
},
|
||||
|
||||
async recoverPasskeyAccount(did: string, recoveryToken: string, newPassword: string): Promise<{ success: boolean }> {
|
||||
return xrpc('com.tranquil.account.recoverPasskeyAccount', {
|
||||
method: 'POST',
|
||||
body: { did, recoveryToken, newPassword },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
{submitting ? 'Redirecting...' : 'Sign In'}
|
||||
</button>
|
||||
<p class="forgot-link">
|
||||
<a href="#/reset-password">Forgot password?</a>
|
||||
<a href="#/reset-password">Forgot password?</a> · <a href="#/request-passkey-recovery">Lost passkey?</a>
|
||||
</p>
|
||||
<p class="register-link">
|
||||
Don't have an account? <a href="#/register">Create one</a>
|
||||
|
||||
@@ -115,8 +115,8 @@
|
||||
try {
|
||||
const response = await fetch('/oauth/authorize/deny', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `request_uri=${encodeURIComponent(consentData.request_uri)}`
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request_uri: consentData.request_uri })
|
||||
})
|
||||
|
||||
if (response.redirected) {
|
||||
|
||||
@@ -399,9 +399,28 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="help-links">
|
||||
<a href="#/reset-password">Forgot password?</a> · <a href="#/request-passkey-recovery">Lost passkey?</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.help-links {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.help-links a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.help-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.oauth-login-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto;
|
||||
|
||||
304
frontend/src/routes/OAuthPasskey.svelte
Normal file
304
frontend/src/routes/OAuthPasskey.svelte
Normal file
@@ -0,0 +1,304 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
|
||||
let loading = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let autoStarted = $state(false)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
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 prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
|
||||
return {
|
||||
...options.publicKey,
|
||||
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
|
||||
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64UrlToArrayBuffer(cred.id)
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
|
||||
async function startPasskeyAuth() {
|
||||
const requestUri = getRequestUri()
|
||||
if (!requestUri) {
|
||||
error = 'Missing request_uri parameter'
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
error = 'Passkeys are not supported in this browser'
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const startResponse = await fetch(`/oauth/authorize/passkey?request_uri=${encodeURIComponent(requestUri)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!startResponse.ok) {
|
||||
const data = await startResponse.json()
|
||||
error = data.error_description || data.error || 'Failed to start passkey authentication'
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
const { options } = await startResponse.json()
|
||||
const publicKeyOptions = prepareAuthOptions(options)
|
||||
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions
|
||||
})
|
||||
|
||||
if (!credential) {
|
||||
error = 'Passkey authentication was cancelled'
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
const pkCredential = credential as PublicKeyCredential
|
||||
const response = pkCredential.response as AuthenticatorAssertionResponse
|
||||
const credentialResponse = {
|
||||
id: pkCredential.id,
|
||||
type: pkCredential.type,
|
||||
rawId: arrayBufferToBase64Url(pkCredential.rawId),
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
|
||||
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
|
||||
signature: arrayBufferToBase64Url(response.signature),
|
||||
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
|
||||
},
|
||||
}
|
||||
|
||||
const finishResponse = await fetch('/oauth/authorize/passkey', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
credential: credentialResponse
|
||||
})
|
||||
})
|
||||
|
||||
const finishData = await finishResponse.json()
|
||||
|
||||
if (!finishResponse.ok) {
|
||||
error = finishData.error_description || finishData.error || 'Passkey verification failed'
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
if (finishData.redirect_uri) {
|
||||
window.location.href = finishData.redirect_uri
|
||||
return
|
||||
}
|
||||
|
||||
error = 'Unexpected response from server'
|
||||
loading = false
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'NotAllowedError') {
|
||||
error = 'Passkey authentication was cancelled'
|
||||
} else {
|
||||
error = 'Failed to authenticate with passkey'
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
const requestUri = getRequestUri()
|
||||
if (requestUri) {
|
||||
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
|
||||
} else {
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!autoStarted) {
|
||||
autoStarted = true
|
||||
startPasskeyAuth()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="oauth-passkey-container">
|
||||
<h1>Sign In with Passkey</h1>
|
||||
<p class="subtitle">
|
||||
Your account uses a passkey for authentication. Use your fingerprint, face, or security key to sign in.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="passkey-status">
|
||||
{#if loading}
|
||||
<div class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
<p>Waiting for passkey...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="passkey-btn" onclick={startPasskeyAuth} disabled={loading}>
|
||||
Use Passkey
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={loading}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="help-text">
|
||||
If you've lost access to your passkey, you can recover your account using email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.oauth-passkey-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.passkey-status {
|
||||
padding: 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.passkey-btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.passkey-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.passkey-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 0.75rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.cancel-btn:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
border-color: var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.cancel-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
|
||||
let code = $state('')
|
||||
let trustDevice = $state(false)
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
@@ -30,7 +31,8 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_uri: requestUri,
|
||||
code: code.trim().toUpperCase()
|
||||
code: code.trim().toUpperCase(),
|
||||
trust_device: trustDevice
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,6 +106,15 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="trust-device-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={trustDevice}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span>Trust this device for 30 days</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
|
||||
Cancel
|
||||
@@ -222,4 +233,19 @@
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.trust-device-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.trust-device-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
266
frontend/src/routes/RecoverPasskey.svelte
Normal file
266
frontend/src/routes/RecoverPasskey.svelte
Normal file
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
let newPassword = $state('')
|
||||
let confirmPassword = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let success = $state(false)
|
||||
|
||||
function getUrlParams(): { did: string | null; token: string | null } {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
return {
|
||||
did: params.get('did'),
|
||||
token: params.get('token'),
|
||||
}
|
||||
}
|
||||
|
||||
let { did, token } = getUrlParams()
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!newPassword) return 'New password is required'
|
||||
if (newPassword.length < 8) return 'Password must be at least 8 characters'
|
||||
if (newPassword !== confirmPassword) return 'Passwords do not match'
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!did || !token) {
|
||||
error = 'Invalid recovery link. Please request a new one.'
|
||||
return
|
||||
}
|
||||
|
||||
const validationError = validateForm()
|
||||
if (validationError) {
|
||||
error = validationError
|
||||
return
|
||||
}
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
await api.recoverPasskeyAccount(did, token, newPassword)
|
||||
success = true
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.error === 'RecoveryLinkExpired') {
|
||||
error = 'This recovery link has expired. Please request a new one.'
|
||||
} else if (err.error === 'InvalidRecoveryLink') {
|
||||
error = 'Invalid recovery link. Please request a new one.'
|
||||
} else {
|
||||
error = err.message || 'Recovery failed'
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
error = err.message || 'Recovery failed'
|
||||
} else {
|
||||
error = 'Recovery failed'
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
function requestNewLink() {
|
||||
navigate('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="recover-container">
|
||||
{#if !did || !token}
|
||||
<h1>Invalid Recovery Link</h1>
|
||||
<p class="error-message">
|
||||
This recovery link is invalid or has been corrupted. Please request a new recovery email.
|
||||
</p>
|
||||
<button onclick={requestNewLink}>Go to Login</button>
|
||||
{:else if success}
|
||||
<div class="success-content">
|
||||
<div class="success-icon">✔</div>
|
||||
<h1>Password Set!</h1>
|
||||
<p class="success-message">
|
||||
Your temporary password has been set. You can now sign in with this password.
|
||||
</p>
|
||||
<p class="next-steps">
|
||||
After signing in, we recommend adding a new passkey in your security settings
|
||||
to restore passkey-only authentication.
|
||||
</p>
|
||||
<button onclick={goToLogin}>Sign In</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>Recover Your Account</h1>
|
||||
<p class="subtitle">
|
||||
Set a temporary password to regain access to your passkey-only account.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="field">
|
||||
<label for="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="At least 8 characters"
|
||||
disabled={submitting}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm your password"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>What happens next?</strong>
|
||||
<p>
|
||||
After setting this password, you can sign in and add a new passkey in your security settings.
|
||||
Once you have a new passkey, you can optionally remove the temporary password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Setting password...' : 'Set Password'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recover-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: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--success-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -342,6 +342,9 @@
|
||||
<p class="login-link">
|
||||
Already have an account? <a href="#/login">Sign in</a>
|
||||
</p>
|
||||
<p class="login-link">
|
||||
Want passwordless security? <a href="#/register-passkey">Create a passkey account</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
|
||||
1011
frontend/src/routes/RegisterPasskey.svelte
Normal file
1011
frontend/src/routes/RegisterPasskey.svelte
Normal file
File diff suppressed because it is too large
Load Diff
205
frontend/src/routes/RequestPasskeyRecovery.svelte
Normal file
205
frontend/src/routes/RequestPasskeyRecovery.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
let identifier = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let success = $state(false)
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
await api.requestPasskeyRecovery(identifier)
|
||||
success = true
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error = err.message || 'Failed to send recovery link'
|
||||
} else if (err instanceof Error) {
|
||||
error = err.message || 'Failed to send recovery link'
|
||||
} else {
|
||||
error = 'Failed to send recovery link'
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="recovery-container">
|
||||
{#if success}
|
||||
<div class="success-content">
|
||||
<h1>Recovery Link Sent</h1>
|
||||
<p class="subtitle">
|
||||
If your account exists and is a passkey-only account, you'll receive a recovery link
|
||||
at your preferred notification channel.
|
||||
</p>
|
||||
<p class="info">
|
||||
The link will expire in 1 hour. Check your email, Discord, Telegram, or Signal
|
||||
depending on your account settings.
|
||||
</p>
|
||||
<button onclick={() => navigate('/login')}>Back to Sign In</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>Recover Passkey Account</h1>
|
||||
<p class="subtitle">
|
||||
Lost access to your passkey? Enter your handle or email and we'll send you a recovery link.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="field">
|
||||
<label for="identifier">Handle or Email</label>
|
||||
<input
|
||||
id="identifier"
|
||||
type="text"
|
||||
bind:value={identifier}
|
||||
placeholder="handle or you@example.com"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>How it works</strong>
|
||||
<p>
|
||||
We'll send a secure link to your registered notification channel.
|
||||
Click the link to set a temporary password. Then you can sign in
|
||||
and add a new passkey.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || !identifier.trim()}>
|
||||
{submitting ? 'Sending...' : 'Send Recovery Link'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<p class="back-link">
|
||||
<a href="#/login">Back to Sign In</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recovery-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: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.back-link a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -25,7 +25,7 @@
|
||||
try {
|
||||
await api.requestPasswordReset(email)
|
||||
tokenSent = true
|
||||
success = 'Password reset code sent to your email'
|
||||
success = 'Password reset code sent! Check your preferred notification channel.'
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to send reset code'
|
||||
} finally {
|
||||
@@ -66,7 +66,7 @@
|
||||
{/if}
|
||||
{#if tokenSent}
|
||||
<h1>Reset Password</h1>
|
||||
<p class="subtitle">Enter the code from your email and choose a new password.</p>
|
||||
<p class="subtitle">Enter the code you received and choose a new password.</p>
|
||||
<form onsubmit={handleReset}>
|
||||
<div class="field">
|
||||
<label for="token">Reset Code</label>
|
||||
@@ -74,7 +74,7 @@
|
||||
id="token"
|
||||
type="text"
|
||||
bind:value={token}
|
||||
placeholder="Enter code from email"
|
||||
placeholder="Enter reset code"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
@@ -111,15 +111,15 @@
|
||||
</form>
|
||||
{:else}
|
||||
<h1>Forgot Password</h1>
|
||||
<p class="subtitle">Enter your email address and we'll send you a code to reset your password.</p>
|
||||
<p class="subtitle">Enter your handle or email and we'll send you a code to reset your password.</p>
|
||||
<form onsubmit={handleRequestReset}>
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<label for="email">Handle or Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
type="text"
|
||||
bind:value={email}
|
||||
placeholder="you@example.com"
|
||||
placeholder="handle or you@example.com"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import ReauthModal from '../components/ReauthModal.svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
@@ -38,6 +39,15 @@
|
||||
let editingPasskeyId = $state<string | null>(null)
|
||||
let editPasskeyName = $state('')
|
||||
|
||||
let hasPassword = $state(true)
|
||||
let passwordLoading = $state(true)
|
||||
let showRemovePasswordForm = $state(false)
|
||||
let removePasswordLoading = $state(false)
|
||||
|
||||
let showReauthModal = $state(false)
|
||||
let reauthMethods = $state<string[]>(['password'])
|
||||
let pendingAction = $state<(() => Promise<void>) | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
@@ -48,9 +58,59 @@
|
||||
if (auth.session) {
|
||||
loadTotpStatus()
|
||||
loadPasskeys()
|
||||
loadPasswordStatus()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPasswordStatus() {
|
||||
if (!auth.session) return
|
||||
passwordLoading = true
|
||||
try {
|
||||
const status = await api.getPasswordStatus(auth.session.accessJwt)
|
||||
hasPassword = status.hasPassword
|
||||
} catch {
|
||||
hasPassword = true
|
||||
} finally {
|
||||
passwordLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemovePassword() {
|
||||
if (!auth.session) return
|
||||
removePasswordLoading = true
|
||||
try {
|
||||
await api.removePassword(auth.session.accessJwt)
|
||||
hasPassword = false
|
||||
showRemovePasswordForm = false
|
||||
showMessage('success', 'Password removed. Your account is now passkey-only.')
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.error === 'ReauthRequired') {
|
||||
reauthMethods = e.reauthMethods || ['password']
|
||||
pendingAction = handleRemovePassword
|
||||
showReauthModal = true
|
||||
} else {
|
||||
showMessage('error', e.message)
|
||||
}
|
||||
} else {
|
||||
showMessage('error', 'Failed to remove password')
|
||||
}
|
||||
} finally {
|
||||
removePasswordLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReauthSuccess() {
|
||||
if (pendingAction) {
|
||||
pendingAction()
|
||||
pendingAction = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleReauthCancel() {
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
async function loadTotpStatus() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
@@ -543,9 +603,83 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Password</h2>
|
||||
<p class="description">
|
||||
Manage your account password. If you have passkeys set up, you can optionally remove your password for a fully passwordless experience.
|
||||
</p>
|
||||
|
||||
{#if passwordLoading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if hasPassword}
|
||||
<div class="status enabled">
|
||||
<span>Password authentication is <strong>enabled</strong></span>
|
||||
</div>
|
||||
|
||||
{#if passkeys.length > 0}
|
||||
{#if !showRemovePasswordForm}
|
||||
<button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}>
|
||||
Remove Password
|
||||
</button>
|
||||
{:else}
|
||||
<div class="inline-form danger-form">
|
||||
<h3>Remove Password</h3>
|
||||
<p class="warning-text">
|
||||
This will make your account passkey-only. You'll only be able to sign in using your registered passkeys.
|
||||
If you lose access to all your passkeys, you can recover your account using your notification channel.
|
||||
</p>
|
||||
<div class="info-box-inline">
|
||||
<strong>Before proceeding:</strong>
|
||||
<ul>
|
||||
<li>Make sure you have at least one reliable passkey registered</li>
|
||||
<li>Consider registering passkeys on multiple devices</li>
|
||||
<li>Ensure your recovery notification channel is up to date</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}>
|
||||
{removePasswordLoading ? 'Removing...' : 'Remove Password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="hint">Add at least one passkey before you can remove your password.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="status passkey-only">
|
||||
<span>Your account is <strong>passkey-only</strong></span>
|
||||
</div>
|
||||
<p class="hint">
|
||||
You sign in using passkeys only. If you ever lose access to your passkeys,
|
||||
you can recover your account using the "Lost passkey?" link on the login page.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Trusted Devices</h2>
|
||||
<p class="description">
|
||||
Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device.
|
||||
</p>
|
||||
<a href="#/trusted-devices" class="section-link">
|
||||
Manage Trusted Devices →
|
||||
</a>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ReauthModal
|
||||
bind:show={showReauthModal}
|
||||
availableMethods={reauthMethods}
|
||||
onSuccess={handleReauthSuccess}
|
||||
onCancel={handleReauthCancel}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 600px;
|
||||
@@ -894,4 +1028,52 @@
|
||||
.add-passkey .field {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
display: inline-block;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status.passkey-only {
|
||||
background: var(--accent);
|
||||
background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15));
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-box-inline {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.info-box-inline strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-box-inline ul {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-box-inline li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
emailLoading = true
|
||||
message = null
|
||||
try {
|
||||
const result = await api.requestEmailUpdate(auth.session.accessJwt)
|
||||
const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail)
|
||||
emailTokenRequired = result.tokenRequired
|
||||
if (emailTokenRequired) {
|
||||
showMessage('success', 'Verification code sent to your current email')
|
||||
|
||||
409
frontend/src/routes/TrustedDevices.svelte
Normal file
409
frontend/src/routes/TrustedDevices.svelte
Normal file
@@ -0,0 +1,409 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
interface TrustedDevice {
|
||||
id: string
|
||||
userAgent: string | null
|
||||
friendlyName: string | null
|
||||
trustedAt: string | null
|
||||
trustedUntil: string | null
|
||||
lastSeenAt: string
|
||||
}
|
||||
|
||||
const auth = getAuthState()
|
||||
let devices = $state<TrustedDevice[]>([])
|
||||
let loading = $state(true)
|
||||
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
let editingDeviceId = $state<string | null>(null)
|
||||
let editDeviceName = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadDevices()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadDevices() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
try {
|
||||
const result = await api.listTrustedDevices(auth.session.accessJwt)
|
||||
devices = result.devices
|
||||
} catch {
|
||||
showMessage('error', 'Failed to load trusted devices')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(type: 'success' | 'error', text: string) {
|
||||
message = { type, text }
|
||||
setTimeout(() => {
|
||||
if (message?.text === text) message = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function handleRevoke(deviceId: string) {
|
||||
if (!auth.session) return
|
||||
if (!confirm('Are you sure you want to revoke trust for this device? You will need to enter your 2FA code next time you log in from this device.')) return
|
||||
try {
|
||||
await api.revokeTrustedDevice(auth.session.accessJwt, deviceId)
|
||||
await loadDevices()
|
||||
showMessage('success', 'Device trust revoked')
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to revoke device')
|
||||
}
|
||||
}
|
||||
|
||||
function startEditDevice(device: TrustedDevice) {
|
||||
editingDeviceId = device.id
|
||||
editDeviceName = device.friendlyName || ''
|
||||
}
|
||||
|
||||
function cancelEditDevice() {
|
||||
editingDeviceId = null
|
||||
editDeviceName = ''
|
||||
}
|
||||
|
||||
async function handleSaveDeviceName() {
|
||||
if (!auth.session || !editingDeviceId || !editDeviceName.trim()) return
|
||||
try {
|
||||
await api.updateTrustedDevice(auth.session.accessJwt, editingDeviceId, editDeviceName.trim())
|
||||
await loadDevices()
|
||||
editingDeviceId = null
|
||||
editDeviceName = ''
|
||||
showMessage('success', 'Device renamed')
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename device')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function parseUserAgent(ua: string | null): string {
|
||||
if (!ua) return 'Unknown device'
|
||||
if (ua.includes('Firefox')) return 'Firefox'
|
||||
if (ua.includes('Chrome')) return 'Chrome'
|
||||
if (ua.includes('Safari')) return 'Safari'
|
||||
if (ua.includes('Edge')) return 'Edge'
|
||||
return 'Browser'
|
||||
}
|
||||
|
||||
function getDaysRemaining(trustedUntil: string | null): number {
|
||||
if (!trustedUntil) return 0
|
||||
const now = new Date()
|
||||
const until = new Date(trustedUntil)
|
||||
const diff = until.getTime() - now.getTime()
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/security" class="back">← Security Settings</a>
|
||||
<h1>Trusted Devices</h1>
|
||||
</header>
|
||||
|
||||
{#if message}
|
||||
<div class="message {message.type}">{message.text}</div>
|
||||
{/if}
|
||||
|
||||
<div class="description">
|
||||
<p>
|
||||
Trusted devices can skip two-factor authentication when logging in.
|
||||
Trust is granted for 30 days and automatically extends when you use the device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if devices.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No trusted devices yet.</p>
|
||||
<p class="hint">When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="device-list">
|
||||
{#each devices as device}
|
||||
<div class="device-card">
|
||||
<div class="device-header">
|
||||
{#if editingDeviceId === device.id}
|
||||
<input
|
||||
type="text"
|
||||
class="edit-name-input"
|
||||
bind:value={editDeviceName}
|
||||
placeholder="Device name"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<button class="btn-small btn-primary" onclick={handleSaveDeviceName}>Save</button>
|
||||
<button class="btn-small btn-secondary" onclick={cancelEditDevice}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3>
|
||||
<button class="btn-icon" onclick={() => startEditDevice(device)} title="Rename">
|
||||
✎
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="device-details">
|
||||
{#if device.userAgent && !device.friendlyName}
|
||||
<p class="detail"><span class="label">Browser:</span> {device.userAgent}</p>
|
||||
{:else if device.userAgent}
|
||||
<p class="detail"><span class="label">Browser:</span> {parseUserAgent(device.userAgent)}</p>
|
||||
{/if}
|
||||
<p class="detail">
|
||||
<span class="label">Last seen:</span> {formatDate(device.lastSeenAt)}
|
||||
</p>
|
||||
{#if device.trustedAt}
|
||||
<p class="detail">
|
||||
<span class="label">Trusted since:</span> {formatDate(device.trustedAt)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if device.trustedUntil}
|
||||
{@const daysRemaining = getDaysRemaining(device.trustedUntil)}
|
||||
<p class="detail trust-expiry" class:expiring-soon={daysRemaining <= 7}>
|
||||
<span class="label">Trust expires:</span>
|
||||
{#if daysRemaining <= 0}
|
||||
Expired
|
||||
{:else if daysRemaining === 1}
|
||||
Tomorrow
|
||||
{:else}
|
||||
In {daysRemaining} days
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="device-actions">
|
||||
<button class="btn-danger" onclick={() => handleRevoke(device.id)}>
|
||||
Revoke Trust
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
border: 1px solid var(--success-border);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.description {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.description p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.device-header h3 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.edit-name-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-details {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail .label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.trust-expiry.expiring-soon {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.device-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
</style>
|
||||
5
migrations/20251225_passwordless_accounts.sql
Normal file
5
migrations/20251225_passwordless_accounts.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||
ALTER TABLE users ADD COLUMN password_required BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE users ADD COLUMN recovery_token TEXT;
|
||||
ALTER TABLE users ADD COLUMN recovery_token_expires_at TIMESTAMPTZ;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_recovery_token ON users(recovery_token) WHERE recovery_token IS NOT NULL;
|
||||
4
migrations/20251226_trusted_devices.sql
Normal file
4
migrations/20251226_trusted_devices.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE oauth_device ADD COLUMN trusted_at TIMESTAMPTZ;
|
||||
ALTER TABLE oauth_device ADD COLUMN trusted_until TIMESTAMPTZ;
|
||||
ALTER TABLE oauth_device ADD COLUMN friendly_name TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_device_trusted ON oauth_device(trusted_until) WHERE trusted_until IS NOT NULL;
|
||||
1
migrations/20251227_reauth_tracking.sql
Normal file
1
migrations/20251227_reauth_tracking.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE session_tokens ADD COLUMN last_reauth_at TIMESTAMPTZ;
|
||||
1
migrations/20251228_add_passkey_recovery_comms_type.sql
Normal file
1
migrations/20251228_add_passkey_recovery_comms_type.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'passkey_recovery';
|
||||
@@ -139,12 +139,18 @@ pub async fn create_account(
|
||||
info!(did = %migration_did, "Processing account migration");
|
||||
}
|
||||
|
||||
let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname_for_validation =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let pds_suffix = format!(".{}", hostname_for_validation);
|
||||
|
||||
let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
|
||||
let validated_short_handle = if !input.handle.contains('.')
|
||||
|| input.handle.ends_with(&pds_suffix)
|
||||
{
|
||||
let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
|
||||
input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle)
|
||||
input
|
||||
.handle
|
||||
.strip_suffix(&pds_suffix)
|
||||
.unwrap_or(&input.handle)
|
||||
} else {
|
||||
&input.handle
|
||||
};
|
||||
@@ -166,6 +172,15 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
for c in input.handle.chars() {
|
||||
if !c.is_ascii_alphanumeric() && c != '.' && c != '-' {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": format!("Handle contains invalid character: {}", c)})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
input.handle.to_lowercase()
|
||||
};
|
||||
let email: Option<String> = input
|
||||
@@ -319,7 +334,9 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await {
|
||||
if let Err(e) =
|
||||
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
@@ -335,7 +352,10 @@ pub async fn create_account(
|
||||
info!(did = %d, "Migration with existing did:plc");
|
||||
d.clone()
|
||||
} else if d.starts_with("did:web:") {
|
||||
if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await {
|
||||
if let Err(e) =
|
||||
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref())
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
@@ -758,22 +778,24 @@ pub async fn create_account(
|
||||
};
|
||||
|
||||
if !is_migration
|
||||
&& let Err(e) = sqlx::query!(
|
||||
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)",
|
||||
user_id,
|
||||
verification_code,
|
||||
email,
|
||||
code_expires_at
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await {
|
||||
error!("Error inserting verification code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
&& let Some(ref recipient) = verification_recipient
|
||||
&& let Err(e) = sqlx::query!(
|
||||
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
|
||||
user_id,
|
||||
verification_channel as _,
|
||||
verification_code,
|
||||
recipient,
|
||||
code_expires_at
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
.execute(&mut *tx)
|
||||
.await {
|
||||
error!("Error inserting verification code: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
@@ -919,8 +941,7 @@ pub async fn create_account(
|
||||
}
|
||||
if !is_migration {
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
|
||||
.await
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
|
||||
@@ -674,13 +674,9 @@ pub async fn update_handle(
|
||||
if let Some(old) = old_handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", old)).await;
|
||||
}
|
||||
let _ = state
|
||||
.cache
|
||||
.delete(&format!("handle:{}", handle))
|
||||
.await;
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle))
|
||||
.await
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
|
||||
@@ -419,7 +419,11 @@ pub async fn delete_account(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let password_valid = if verify(password, &password_hash).unwrap_or(false) {
|
||||
let password_valid = if password_hash
|
||||
.as_ref()
|
||||
.map(|h| verify(password, h).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
true
|
||||
} else {
|
||||
let app_pass_rows = sqlx::query!(
|
||||
|
||||
@@ -3,12 +3,15 @@ pub mod app_password;
|
||||
pub mod email;
|
||||
pub mod invite;
|
||||
pub mod meta;
|
||||
pub mod passkey_account;
|
||||
pub mod passkeys;
|
||||
pub mod password;
|
||||
pub mod reauth;
|
||||
pub mod service_auth;
|
||||
pub mod session;
|
||||
pub mod signing_key;
|
||||
pub mod totp;
|
||||
pub mod trusted_devices;
|
||||
|
||||
pub use account_status::{
|
||||
activate_account, check_account_status, deactivate_account, delete_account,
|
||||
@@ -18,11 +21,21 @@ 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 passkey_account::{
|
||||
complete_passkey_setup, create_passkey_account, recover_passkey_account,
|
||||
request_passkey_recovery, start_passkey_registration_for_setup,
|
||||
};
|
||||
pub use passkeys::{
|
||||
delete_passkey, finish_passkey_registration, has_passkeys_for_user, has_passkeys_for_user_db,
|
||||
list_passkeys, start_passkey_registration, update_passkey,
|
||||
};
|
||||
pub use password::{
|
||||
change_password, get_password_status, remove_password, request_password_reset, reset_password,
|
||||
};
|
||||
pub use reauth::{
|
||||
check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start,
|
||||
reauth_password, reauth_required_response, reauth_totp,
|
||||
};
|
||||
pub use password::{change_password, request_password_reset, reset_password};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{
|
||||
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
|
||||
@@ -31,5 +44,9 @@ pub use 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,
|
||||
has_totp_enabled_db, regenerate_backup_codes, verify_totp_or_backup_for_user,
|
||||
};
|
||||
pub use trusted_devices::{
|
||||
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
|
||||
trust_device, update_trusted_device,
|
||||
};
|
||||
|
||||
1209
src/api/server/passkey_account.rs
Normal file
1209
src/api/server/passkey_account.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -371,7 +371,9 @@ pub async fn update_passkey(
|
||||
}
|
||||
|
||||
pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool {
|
||||
webauthn::has_passkeys(&state.db, did)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
has_passkeys_for_user_db(&state.db, did).await
|
||||
}
|
||||
|
||||
pub async fn has_passkeys_for_user_db(db: &sqlx::PgPool, did: &str) -> bool {
|
||||
webauthn::has_passkeys(db, did).await.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordResetInput {
|
||||
#[serde(alias = "identifier")]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
@@ -56,21 +57,33 @@ pub async fn request_password_reset(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let email = input.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
let identifier = input.email.trim();
|
||||
if identifier.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "email is required"})),
|
||||
Json(json!({"error": "InvalidRequest", "message": "email or handle is required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let user = sqlx::query!("SELECT id FROM users WHERE LOWER(email) = $1", email)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let normalized = identifier.to_lowercase();
|
||||
let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
|
||||
let normalized_handle = if normalized.contains('@') || normalized.contains('.') {
|
||||
normalized.to_string()
|
||||
} else {
|
||||
format!("{}.{}", normalized, pds_hostname)
|
||||
};
|
||||
let user = sqlx::query!(
|
||||
"SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2",
|
||||
normalized,
|
||||
normalized_handle
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let user_id = match user {
|
||||
Ok(Some(row)) => row.id,
|
||||
Ok(None) => {
|
||||
info!("Password reset requested for unknown email");
|
||||
info!("Password reset requested for unknown identifier");
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -225,7 +238,7 @@ pub async fn reset_password(
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query!(
|
||||
"UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2",
|
||||
"UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2",
|
||||
password_hash,
|
||||
user_id
|
||||
)
|
||||
@@ -404,3 +417,105 @@ pub async fn change_password(
|
||||
info!(did = %auth.0.did, "Password changed successfully");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let user = sqlx::query!(
|
||||
"SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
match user {
|
||||
Ok(Some(row)) => {
|
||||
Json(json!({"hasPassword": row.has_password.unwrap_or(false)})).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
|
||||
}
|
||||
|
||||
let has_passkeys =
|
||||
crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await;
|
||||
if !has_passkeys {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoPasskeys",
|
||||
"message": "You must have at least one passkey registered before removing your password"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, password_hash FROM users WHERE did = $1",
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let user = match user {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.password_hash.is_none() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoPassword",
|
||||
"message": "Account already has no password"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1",
|
||||
user.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error removing password: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, "Password removed - account is now passkey-only");
|
||||
(StatusCode::OK, Json(json!({"success": true}))).into_response()
|
||||
}
|
||||
|
||||
482
src/api/server/reauth.rs
Normal file
482
src/api/server/reauth.rs
Normal file
@@ -0,0 +1,482 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::AppState;
|
||||
|
||||
const REAUTH_WINDOW_SECONDS: i64 = 300;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReauthStatusResponse {
|
||||
pub last_reauth_at: Option<DateTime<Utc>>,
|
||||
pub reauth_required: bool,
|
||||
pub available_methods: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let session = sqlx::query!(
|
||||
"SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let last_reauth_at = match session {
|
||||
Ok(Some(row)) => row.last_reauth_at,
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let reauth_required = is_reauth_required(last_reauth_at);
|
||||
let available_methods = get_available_reauth_methods(&state.db, &auth.0.did).await;
|
||||
|
||||
Json(ReauthStatusResponse {
|
||||
last_reauth_at,
|
||||
reauth_required,
|
||||
available_methods,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordReauthInput {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReauthResponse {
|
||||
pub reauthed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn reauth_password(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<PasswordReauthInput>,
|
||||
) -> 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"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let password_valid = password_hash
|
||||
.as_ref()
|
||||
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !password_valid {
|
||||
let app_passwords = sqlx::query!(
|
||||
"SELECT ap.password_hash FROM app_passwords ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE u.did = $1",
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let app_password_valid = app_passwords
|
||||
.iter()
|
||||
.any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false));
|
||||
|
||||
if !app_password_valid {
|
||||
warn!(did = %auth.0.did, "Re-auth failed: invalid password");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": "Password is incorrect"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
match update_last_reauth(&state.db, &auth.0.did).await {
|
||||
Ok(reauthed_at) => {
|
||||
info!(did = %auth.0.did, "Re-auth successful via password");
|
||||
Json(ReauthResponse { reauthed_at }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TotpReauthInput {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn reauth_totp(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<TotpReauthInput>,
|
||||
) -> Response {
|
||||
let valid =
|
||||
crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code)
|
||||
.await;
|
||||
|
||||
if !valid {
|
||||
warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code");
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "InvalidCode",
|
||||
"message": "Invalid TOTP or backup code"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match update_last_reauth(&state.db, &auth.0.did).await {
|
||||
Ok(reauthed_at) => {
|
||||
info!(did = %auth.0.did, "Re-auth successful via TOTP");
|
||||
Json(ReauthResponse { reauthed_at }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyReauthStartResponse {
|
||||
pub options: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
|
||||
let stored_passkeys =
|
||||
match crate::auth::webauthn::get_passkeys_for_user(&state.db, &auth.0.did).await {
|
||||
Ok(pks) => pks,
|
||||
Err(e) => {
|
||||
error!("Failed to get passkeys: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if stored_passkeys.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoPasskeys",
|
||||
"message": "No passkeys registered for this account"
|
||||
})),
|
||||
)
|
||||
.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(json!({"error": "InternalError", "message": "Failed to load passkeys"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let (rcr, auth_state) = match webauthn.start_authentication(passkeys) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
error!("Failed to start passkey authentication: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await
|
||||
{
|
||||
error!("Failed to save authentication state: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let options = serde_json::to_value(&rcr).unwrap_or(json!({}));
|
||||
Json(PasskeyReauthStartResponse { options }).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyReauthFinishInput {
|
||||
pub credential: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn reauth_passkey_finish(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<PasskeyReauthFinishInput>,
|
||||
) -> Response {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
|
||||
let auth_state =
|
||||
match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "NoChallengeInProgress",
|
||||
"message": "No passkey authentication in progress or challenge expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to load authentication state: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let credential: webauthn_rs::prelude::PublicKeyCredential =
|
||||
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 webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "AuthenticationFailed",
|
||||
"message": "Passkey authentication failed"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let cred_id_bytes = auth_result.cred_id().as_ref();
|
||||
if let Err(e) = crate::auth::webauthn::update_passkey_counter(
|
||||
&state.db,
|
||||
cred_id_bytes,
|
||||
auth_result.counter(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update passkey counter: {:?}", e);
|
||||
}
|
||||
|
||||
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
|
||||
|
||||
match update_last_reauth(&state.db, &auth.0.did).await {
|
||||
Ok(reauthed_at) => {
|
||||
info!(did = %auth.0.did, "Re-auth successful via passkey");
|
||||
Json(ReauthResponse { reauthed_at }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error updating reauth: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> {
|
||||
let now = Utc::now();
|
||||
sqlx::query!(
|
||||
"UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
|
||||
now,
|
||||
did
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
fn is_reauth_required(last_reauth_at: Option<DateTime<Utc>>) -> bool {
|
||||
match last_reauth_at {
|
||||
None => true,
|
||||
Some(t) => {
|
||||
let elapsed = Utc::now().signed_duration_since(t);
|
||||
elapsed.num_seconds() > REAUTH_WINDOW_SECONDS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> {
|
||||
let mut methods = Vec::new();
|
||||
|
||||
let has_password = sqlx::query_scalar!(
|
||||
"SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(Some(false));
|
||||
|
||||
if has_password == Some(true) {
|
||||
methods.push("password".to_string());
|
||||
}
|
||||
|
||||
let has_app_password = sqlx::query_scalar!(
|
||||
"SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if has_app_password && !methods.contains(&"password".to_string()) {
|
||||
methods.push("password".to_string());
|
||||
}
|
||||
|
||||
let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await;
|
||||
if has_totp {
|
||||
methods.push("totp".to_string());
|
||||
}
|
||||
|
||||
let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(db, did).await;
|
||||
if has_passkeys {
|
||||
methods.push("passkey".to_string());
|
||||
}
|
||||
|
||||
methods
|
||||
}
|
||||
|
||||
pub async fn check_reauth_required(db: &PgPool, did: &str) -> bool {
|
||||
let session = sqlx::query!(
|
||||
"SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await;
|
||||
|
||||
match session {
|
||||
Ok(Some(row)) => is_reauth_required(row.last_reauth_at),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReauthRequiredError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
pub reauth_methods: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response {
|
||||
let methods = get_available_reauth_methods(db, did).await;
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(ReauthRequiredError {
|
||||
error: "ReauthRequired".to_string(),
|
||||
message: "Re-authentication required for this action".to_string(),
|
||||
reauth_methods: methods,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -63,7 +63,10 @@ pub async fn create_session(
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<CreateSessionInput>,
|
||||
) -> Response {
|
||||
info!("create_session called with identifier: {}", input.identifier);
|
||||
info!(
|
||||
"create_session called with identifier: {}",
|
||||
input.identifier
|
||||
);
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::Login, &client_ip)
|
||||
@@ -81,7 +84,10 @@ pub async fn create_session(
|
||||
}
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
|
||||
info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier);
|
||||
info!(
|
||||
"Normalized identifier: {} -> {}",
|
||||
input.identifier, normalized_identifier
|
||||
);
|
||||
let row = match sqlx::query!(
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle, u.password_hash,
|
||||
@@ -117,7 +123,12 @@ pub async fn create_session(
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let password_valid = if verify(&input.password, &row.password_hash).unwrap_or(false) {
|
||||
let password_valid = if row
|
||||
.password_hash
|
||||
.as_ref()
|
||||
.map(|h| verify(&input.password, h).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
true
|
||||
} else {
|
||||
let app_passwords = sqlx::query!(
|
||||
@@ -523,9 +534,16 @@ pub async fn confirm_signup(
|
||||
}
|
||||
};
|
||||
|
||||
let channel_str = match row.channel {
|
||||
crate::comms::CommsChannel::Email => "email",
|
||||
crate::comms::CommsChannel::Discord => "discord",
|
||||
crate::comms::CommsChannel::Telegram => "telegram",
|
||||
crate::comms::CommsChannel::Signal => "signal",
|
||||
};
|
||||
let verification = match sqlx::query!(
|
||||
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
|
||||
row.id
|
||||
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
|
||||
row.id,
|
||||
channel_str as _
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
@@ -574,8 +592,9 @@ pub async fn confirm_signup(
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
|
||||
row.id
|
||||
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
|
||||
row.id,
|
||||
channel_str as _
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@@ -676,18 +695,31 @@ pub async fn resend_verification(
|
||||
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
||||
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
|
||||
|
||||
let email = row.email.clone();
|
||||
let (channel_str, recipient) = match row.channel {
|
||||
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
crate::comms::CommsChannel::Discord => {
|
||||
("discord", row.discord_id.clone().unwrap_or_default())
|
||||
}
|
||||
crate::comms::CommsChannel::Telegram => (
|
||||
"telegram",
|
||||
row.telegram_username.clone().unwrap_or_default(),
|
||||
),
|
||||
crate::comms::CommsChannel::Signal => {
|
||||
("signal", row.signal_number.clone().unwrap_or_default())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
|
||||
VALUES ($1, 'email', $2, $3, $4)
|
||||
VALUES ($1, $2::comms_channel, $3, $4, $5)
|
||||
ON CONFLICT (user_id, channel) DO UPDATE
|
||||
SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()
|
||||
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
|
||||
"#,
|
||||
row.id,
|
||||
channel_str as _,
|
||||
verification_code,
|
||||
email,
|
||||
recipient,
|
||||
code_expires_at
|
||||
)
|
||||
.execute(&state.db)
|
||||
@@ -696,14 +728,6 @@ pub async fn resend_verification(
|
||||
error!("Failed to update verification code: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
let (channel_str, recipient) = match row.channel {
|
||||
crate::comms::CommsChannel::Email => ("email", row.email.unwrap_or_default()),
|
||||
crate::comms::CommsChannel::Discord => ("discord", row.discord_id.unwrap_or_default()),
|
||||
crate::comms::CommsChannel::Telegram => {
|
||||
("telegram", row.telegram_username.unwrap_or_default())
|
||||
}
|
||||
crate::comms::CommsChannel::Signal => ("signal", row.signal_number.unwrap_or_default()),
|
||||
};
|
||||
if let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
&state.db,
|
||||
row.id,
|
||||
|
||||
@@ -332,7 +332,10 @@ pub async fn disable_totp(
|
||||
}
|
||||
};
|
||||
|
||||
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
|
||||
let password_valid = password_hash
|
||||
.as_ref()
|
||||
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if !password_valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -536,7 +539,10 @@ pub async fn regenerate_backup_codes(
|
||||
}
|
||||
};
|
||||
|
||||
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
|
||||
let password_valid = password_hash
|
||||
.as_ref()
|
||||
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if !password_valid {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -741,8 +747,12 @@ pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &
|
||||
}
|
||||
|
||||
pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
|
||||
has_totp_enabled_db(&state.db, did).await
|
||||
}
|
||||
|
||||
pub async fn has_totp_enabled_db(db: &sqlx::PgPool, did: &str) -> bool {
|
||||
let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.fetch_optional(db)
|
||||
.await;
|
||||
|
||||
matches!(result, Ok(Some(true)))
|
||||
|
||||
246
src/api/server/trusted_devices.rs
Normal file
246
src/api/server/trusted_devices.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::AppState;
|
||||
|
||||
const TRUST_DURATION_DAYS: i64 = 30;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustedDevice {
|
||||
pub id: String,
|
||||
pub user_agent: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub trusted_at: Option<DateTime<Utc>>,
|
||||
pub trusted_until: Option<DateTime<Utc>>,
|
||||
pub last_seen_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListTrustedDevicesResponse {
|
||||
pub devices: Vec<TrustedDevice>,
|
||||
}
|
||||
|
||||
pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let devices = sqlx::query!(
|
||||
r#"SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at
|
||||
FROM oauth_device od
|
||||
JOIN oauth_account_device oad ON od.id = oad.device_id
|
||||
WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()
|
||||
ORDER BY od.last_seen_at DESC"#,
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
|
||||
match devices {
|
||||
Ok(rows) => {
|
||||
let devices = rows
|
||||
.into_iter()
|
||||
.map(|row| TrustedDevice {
|
||||
id: row.id,
|
||||
user_agent: row.user_agent,
|
||||
friendly_name: row.friendly_name,
|
||||
trusted_at: row.trusted_at,
|
||||
trusted_until: row.trusted_until,
|
||||
last_seen_at: row.last_seen_at,
|
||||
})
|
||||
.collect();
|
||||
Json(ListTrustedDevicesResponse { devices }).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RevokeTrustedDeviceInput {
|
||||
pub device_id: String,
|
||||
}
|
||||
|
||||
pub async fn revoke_trusted_device(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<RevokeTrustedDeviceInput>,
|
||||
) -> Response {
|
||||
let device_exists = sqlx::query_scalar!(
|
||||
r#"SELECT 1 as one FROM oauth_device od
|
||||
JOIN oauth_account_device oad ON od.id = oad.device_id
|
||||
WHERE oad.did = $1 AND od.id = $2"#,
|
||||
auth.0.did,
|
||||
input.device_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
match device_exists {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
|
||||
input.device_id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device revoked");
|
||||
Json(json!({"success": true})).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateTrustedDeviceInput {
|
||||
pub device_id: String,
|
||||
pub friendly_name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_trusted_device(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<UpdateTrustedDeviceInput>,
|
||||
) -> Response {
|
||||
let device_exists = sqlx::query_scalar!(
|
||||
r#"SELECT 1 as one FROM oauth_device od
|
||||
JOIN oauth_account_device oad ON od.id = oad.device_id
|
||||
WHERE oad.did = $1 AND od.id = $2"#,
|
||||
auth.0.did,
|
||||
input.device_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
match device_exists {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"UPDATE oauth_device SET friendly_name = $1 WHERE id = $2",
|
||||
input.friendly_name,
|
||||
input.device_id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated");
|
||||
Json(json!({"success": true})).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
|
||||
let result = sqlx::query_scalar!(
|
||||
r#"SELECT trusted_until FROM oauth_device od
|
||||
JOIN oauth_account_device oad ON od.id = oad.device_id
|
||||
WHERE od.id = $1 AND oad.did = $2"#,
|
||||
device_id,
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(Some(trusted_until))) => trusted_until > Utc::now(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
|
||||
let now = Utc::now();
|
||||
let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3",
|
||||
now,
|
||||
trusted_until,
|
||||
device_id
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn extend_device_trust(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
|
||||
let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS);
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL",
|
||||
trusted_until,
|
||||
device_id
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,10 +22,23 @@ impl std::fmt::Display for HandleValidationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Empty => write!(f, "Handle cannot be empty"),
|
||||
Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH),
|
||||
Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH),
|
||||
Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"),
|
||||
Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"),
|
||||
Self::TooShort => write!(
|
||||
f,
|
||||
"Handle must be at least {} characters",
|
||||
MIN_HANDLE_LENGTH
|
||||
),
|
||||
Self::TooLong => write!(
|
||||
f,
|
||||
"Handle exceeds maximum length of {} characters",
|
||||
MAX_HANDLE_LENGTH
|
||||
),
|
||||
Self::InvalidCharacters => write!(
|
||||
f,
|
||||
"Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"
|
||||
),
|
||||
Self::StartsWithInvalidChar => {
|
||||
write!(f, "Handle cannot start with a hyphen or underscore")
|
||||
}
|
||||
Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"),
|
||||
Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"),
|
||||
}
|
||||
@@ -125,28 +138,76 @@ mod tests {
|
||||
fn test_valid_handles() {
|
||||
assert_eq!(validate_short_handle("alice"), Ok("alice".to_string()));
|
||||
assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string()));
|
||||
assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string()));
|
||||
assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string()));
|
||||
assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string()));
|
||||
assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string()));
|
||||
assert_eq!(
|
||||
validate_short_handle("user-name"),
|
||||
Ok("user-name".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("user_name"),
|
||||
Ok("user_name".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("UPPERCASE"),
|
||||
Ok("uppercase".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("MixedCase123"),
|
||||
Ok("mixedcase123".to_string())
|
||||
);
|
||||
assert_eq!(validate_short_handle("abc"), Ok("abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_handles() {
|
||||
assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty));
|
||||
assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty));
|
||||
assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort));
|
||||
assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort));
|
||||
assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces));
|
||||
assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces));
|
||||
assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar));
|
||||
assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar));
|
||||
assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar));
|
||||
assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar));
|
||||
assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters));
|
||||
assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters));
|
||||
assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters));
|
||||
assert_eq!(
|
||||
validate_short_handle(" "),
|
||||
Err(HandleValidationError::Empty)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("ab"),
|
||||
Err(HandleValidationError::TooShort)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("a"),
|
||||
Err(HandleValidationError::TooShort)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("test spaces"),
|
||||
Err(HandleValidationError::ContainsSpaces)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("test\ttab"),
|
||||
Err(HandleValidationError::ContainsSpaces)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("-starts"),
|
||||
Err(HandleValidationError::StartsWithInvalidChar)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("_starts"),
|
||||
Err(HandleValidationError::StartsWithInvalidChar)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("ends-"),
|
||||
Err(HandleValidationError::EndsWithInvalidChar)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("ends_"),
|
||||
Err(HandleValidationError::EndsWithInvalidChar)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("test@user"),
|
||||
Err(HandleValidationError::InvalidCharacters)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("test!user"),
|
||||
Err(HandleValidationError::InvalidCharacters)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_short_handle("test.user"),
|
||||
Err(HandleValidationError::InvalidCharacters)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,8 +9,8 @@ pub use sender::{
|
||||
|
||||
pub use service::{
|
||||
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
|
||||
enqueue_email_update, enqueue_email_verification, enqueue_password_reset,
|
||||
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
|
||||
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
|
||||
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
|
||||
};
|
||||
|
||||
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
||||
|
||||
@@ -457,6 +457,31 @@ pub async fn enqueue_2fa_code(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_passkey_recovery(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
recovery_url: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let body = format!(
|
||||
"Hello @{},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.",
|
||||
prefs.handle, recovery_url
|
||||
);
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewComms::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::CommsType::PasskeyRecovery,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Account Recovery - {}", hostname)),
|
||||
body,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn channel_display_name(channel: CommsChannel) -> &'static str {
|
||||
match channel {
|
||||
CommsChannel::Email => "email",
|
||||
|
||||
@@ -32,6 +32,7 @@ pub enum CommsType {
|
||||
AdminEmail,
|
||||
PlcOperation,
|
||||
TwoFactorCode,
|
||||
PasskeyRecovery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
|
||||
68
src/lib.rs
68
src/lib.rs
@@ -202,6 +202,66 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.tranquil.account.changePassword",
|
||||
post(api::server::change_password),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.removePassword",
|
||||
post(api::server::remove_password),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getPasswordStatus",
|
||||
get(api::server::get_password_status),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getReauthStatus",
|
||||
get(api::server::get_reauth_status),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.reauthPassword",
|
||||
post(api::server::reauth_password),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.reauthTotp",
|
||||
post(api::server::reauth_totp),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.reauthPasskeyStart",
|
||||
post(api::server::reauth_passkey_start),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.reauthPasskeyFinish",
|
||||
post(api::server::reauth_passkey_finish),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.listTrustedDevices",
|
||||
get(api::server::list_trusted_devices),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.revokeTrustedDevice",
|
||||
post(api::server::revoke_trusted_device),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateTrustedDevice",
|
||||
post(api::server::update_trusted_device),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.createPasskeyAccount",
|
||||
post(api::server::create_passkey_account),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup",
|
||||
post(api::server::start_passkey_registration_for_setup),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.completePasskeySetup",
|
||||
post(api::server::complete_passkey_setup),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.requestPasskeyRecovery",
|
||||
post(api::server::request_passkey_recovery),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.recoverPasskeyAccount",
|
||||
post(api::server::recover_passkey_account),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.requestEmailUpdate",
|
||||
post(api::server::request_email_update),
|
||||
@@ -399,6 +459,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/oauth/authorize/2fa",
|
||||
post(oauth::endpoints::authorize_2fa_post),
|
||||
)
|
||||
.route(
|
||||
"/oauth/authorize/passkey",
|
||||
get(oauth::endpoints::authorize_passkey_start),
|
||||
)
|
||||
.route(
|
||||
"/oauth/authorize/passkey",
|
||||
post(oauth::endpoints::authorize_passkey_finish),
|
||||
)
|
||||
.route(
|
||||
"/oauth/passkey/check",
|
||||
get(oauth::endpoints::check_user_has_passkeys),
|
||||
|
||||
@@ -441,7 +441,7 @@ pub async fn authorize_post(
|
||||
);
|
||||
let user = match sqlx::query!(
|
||||
r#"
|
||||
SELECT id, did, email, password_hash, two_factor_enabled,
|
||||
SELECT id, did, email, password_hash, password_required, two_factor_enabled,
|
||||
preferred_comms_channel as "preferred_comms_channel: CommsChannel",
|
||||
deactivated_at, takedown_ref,
|
||||
email_verified, discord_verified, telegram_verified, signal_verified
|
||||
@@ -479,31 +479,74 @@ pub async fn authorize_post(
|
||||
json_response,
|
||||
);
|
||||
}
|
||||
let password_valid = match bcrypt::verify(&form.password, &user.password_hash) {
|
||||
Ok(valid) => valid,
|
||||
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
|
||||
};
|
||||
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 !user.password_required {
|
||||
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={}",
|
||||
let redirect_url = format!(
|
||||
"/#/oauth/passkey?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
));
|
||||
);
|
||||
if json_response {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"next": "passkey",
|
||||
"redirect": redirect_url
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&redirect_url);
|
||||
}
|
||||
|
||||
let password_valid = match &user.password_hash {
|
||||
Some(hash) => match bcrypt::verify(&form.password, hash) {
|
||||
Ok(valid) => valid,
|
||||
Err(_) => {
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
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 {
|
||||
let device_cookie = extract_device_cookie(&headers);
|
||||
let device_is_trusted = if let Some(ref dev_id) = device_cookie {
|
||||
crate::api::server::is_device_trusted(&state.db, dev_id, &user.did).await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if device_is_trusted {
|
||||
if let Some(ref dev_id) = device_cookie {
|
||||
let _ = crate::api::server::extend_device_trust(&state.db, dev_id).await;
|
||||
}
|
||||
} else {
|
||||
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;
|
||||
@@ -934,6 +977,8 @@ pub struct Authorize2faQuery {
|
||||
pub struct Authorize2faSubmit {
|
||||
pub request_uri: String,
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub trust_device: bool,
|
||||
}
|
||||
|
||||
const MAX_2FA_ATTEMPTS: i32 = 5;
|
||||
@@ -1475,6 +1520,12 @@ pub async fn authorize_2fa_post(
|
||||
"Invalid verification code. Please try again.",
|
||||
);
|
||||
}
|
||||
let device_id = extract_device_cookie(&headers);
|
||||
if form.trust_device
|
||||
&& let Some(ref dev_id) = device_id
|
||||
{
|
||||
let _ = crate::api::server::trust_device(&state.db, dev_id).await;
|
||||
}
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
@@ -1500,7 +1551,6 @@ pub async fn authorize_2fa_post(
|
||||
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,
|
||||
@@ -2139,3 +2189,371 @@ pub async fn passkey_finish(
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizePasskeyQuery {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasskeyAuthResponse {
|
||||
pub options: serde_json::Value,
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
pub async fn authorize_passkey_start(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<AuthorizePasskeyQuery>,
|
||||
) -> Response {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
|
||||
let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await {
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request not found."
|
||||
})),
|
||||
)
|
||||
.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, &query.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.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "User not authenticated yet."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let stored_passkeys = match crate::auth::webauthn::get_passkeys_for_user(&state.db, &did).await
|
||||
{
|
||||
Ok(pks) => pks,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to get passkeys: {:?}", e);
|
||||
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::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "No passkeys registered for this account."
|
||||
})),
|
||||
)
|
||||
.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!("Failed to create WebAuthn config: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let (rcr, auth_state) = match webauthn.start_authentication(passkeys) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to start passkey authentication: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
crate::auth::webauthn::save_authentication_state(&state.db, &did, &auth_state).await
|
||||
{
|
||||
tracing::error!("Failed to save authentication state: {:?}", e);
|
||||
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(PasskeyAuthResponse {
|
||||
options,
|
||||
request_uri: query.request_uri,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthorizePasskeySubmit {
|
||||
pub request_uri: String,
|
||||
pub credential: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn authorize_passkey_finish(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<AuthorizePasskeySubmit>,
|
||||
) -> Response {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
|
||||
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request not found."
|
||||
})),
|
||||
)
|
||||
.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.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "User not authenticated yet."
|
||||
})),
|
||||
)
|
||||
.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 challenge found. Please start over."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to load authentication state: {:?}", e);
|
||||
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.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse credential: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid credential format."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create WebAuthn config: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Passkey authentication failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Passkey authentication failed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await;
|
||||
|
||||
if let Err(e) = crate::auth::webauthn::update_passkey_counter(
|
||||
&state.db,
|
||||
credential.id.as_ref(),
|
||||
auth_result.counter(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to update passkey counter: {:?}", e);
|
||||
}
|
||||
|
||||
let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await;
|
||||
if has_totp {
|
||||
let device_cookie = extract_device_cookie(&headers);
|
||||
let device_is_trusted = if let Some(ref dev_id) = device_cookie {
|
||||
crate::api::server::is_device_trusted(&state.db, dev_id, &did).await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if device_is_trusted {
|
||||
if let Some(ref dev_id) = device_cookie {
|
||||
let _ = crate::api::server::extend_device_trust(&state.db, dev_id).await;
|
||||
}
|
||||
} else {
|
||||
let user = match sqlx::query!(
|
||||
r#"SELECT id, preferred_comms_channel as "preferred_comms_channel: CommsChannel" FROM users WHERE did = $1"#,
|
||||
did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
if let Err(e) =
|
||||
enqueue_2fa_code(&state.db, user.id, &challenge.code, &pds_hostname).await
|
||||
{
|
||||
tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification");
|
||||
}
|
||||
let channel_name = channel_display_name(user.preferred_comms_channel);
|
||||
let redirect_url = format!(
|
||||
"/#/oauth/2fa?request_uri={}&channel={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(channel_name)
|
||||
);
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"next": "2fa",
|
||||
"redirect": redirect_url
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "server_error", "error_description": "An error occurred."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let redirect_url = format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"next": "consent",
|
||||
"redirect": redirect_url
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -10,24 +10,43 @@ async fn test_search_accounts_as_admin() {
|
||||
let client = client();
|
||||
let (admin_jwt, _) = create_admin_account_and_login(&client).await;
|
||||
let (user_did, _) = setup_new_user("search-target").await;
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts?limit=1000",
|
||||
base_url().await
|
||||
))
|
||||
.bearer_auth(&admin_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let accounts = body["accounts"]
|
||||
.as_array()
|
||||
.expect("accounts should be array");
|
||||
assert!(!accounts.is_empty(), "Should return some accounts");
|
||||
let found = accounts
|
||||
.iter()
|
||||
.any(|a| a["did"].as_str() == Some(&user_did));
|
||||
let mut found = false;
|
||||
let mut cursor: Option<String> = None;
|
||||
for _ in 0..10 {
|
||||
let url = match &cursor {
|
||||
Some(c) => format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts?limit=100&cursor={}",
|
||||
base_url().await,
|
||||
c
|
||||
),
|
||||
None => format!(
|
||||
"{}/xrpc/com.atproto.admin.searchAccounts?limit=100",
|
||||
base_url().await
|
||||
),
|
||||
};
|
||||
let res = client
|
||||
.get(&url)
|
||||
.bearer_auth(&admin_jwt)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
let accounts = body["accounts"]
|
||||
.as_array()
|
||||
.expect("accounts should be array");
|
||||
if accounts
|
||||
.iter()
|
||||
.any(|a| a["did"].as_str() == Some(&user_did))
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
cursor = body["cursor"].as_str().map(|s| s.to_string());
|
||||
if cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found,
|
||||
"Should find the created user in results (DID: {})",
|
||||
|
||||
@@ -97,9 +97,34 @@ async fn test_external_did_web_no_local_doc() {
|
||||
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
|
||||
let handle = format!("extweb_{}", uuid::Uuid::new_v4());
|
||||
let pds_endpoint = base_url().await.replace("http://", "https://");
|
||||
|
||||
let reserve_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({ "did": did }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to reserve signing key");
|
||||
assert_eq!(reserve_res.status(), StatusCode::OK);
|
||||
let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON");
|
||||
let signing_key = reserve_body["signingKey"]
|
||||
.as_str()
|
||||
.expect("No signingKey returned");
|
||||
let public_key_multibase = signing_key
|
||||
.strip_prefix("did:key:")
|
||||
.expect("signingKey should start with did:key:");
|
||||
|
||||
let did_doc = json!({
|
||||
"@context": ["https://www.w3.org/ns/did/v1"],
|
||||
"id": did,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": public_key_multibase
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
@@ -116,7 +141,8 @@ async fn test_external_did_web_no_local_doc() {
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"didType": "web-external",
|
||||
"did": did
|
||||
"did": did,
|
||||
"signingKey": signing_key
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
|
||||
@@ -26,7 +26,10 @@ async fn test_resolve_handle_success() {
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.expect("Invalid JSON");
|
||||
let did = body["did"].as_str().expect("No DID").to_string();
|
||||
let full_handle = body["handle"].as_str().expect("No handle in response").to_string();
|
||||
let full_handle = body["handle"]
|
||||
.as_str()
|
||||
.expect("No handle in response")
|
||||
.to_string();
|
||||
let params = [("handle", full_handle.as_str())];
|
||||
let res = client
|
||||
.get(format!(
|
||||
@@ -97,9 +100,34 @@ async fn test_create_did_web_account_and_resolve() {
|
||||
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
|
||||
let handle = format!("webuser_{}", uuid::Uuid::new_v4());
|
||||
let pds_endpoint = base_url().await.replace("http://", "https://");
|
||||
|
||||
let reserve_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({ "did": did }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to reserve signing key");
|
||||
assert_eq!(reserve_res.status(), StatusCode::OK);
|
||||
let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON");
|
||||
let signing_key = reserve_body["signingKey"]
|
||||
.as_str()
|
||||
.expect("No signingKey returned");
|
||||
let public_key_multibase = signing_key
|
||||
.strip_prefix("did:key:")
|
||||
.expect("signingKey should start with did:key:");
|
||||
|
||||
let did_doc = json!({
|
||||
"@context": ["https://www.w3.org/ns/did/v1"],
|
||||
"id": did,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": public_key_multibase
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
@@ -115,7 +143,8 @@ async fn test_create_did_web_account_and_resolve() {
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"did": did
|
||||
"did": did,
|
||||
"signingKey": signing_key
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
@@ -195,9 +224,34 @@ async fn test_did_web_lifecycle() {
|
||||
let did = format!("did:web:{}:u:{}", mock_addr.replace(":", "%3A"), handle);
|
||||
let email = format!("{}@test.com", handle);
|
||||
let pds_endpoint = base_url().await.replace("http://", "https://");
|
||||
|
||||
let reserve_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.reserveSigningKey",
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({ "did": did }))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to reserve signing key");
|
||||
assert_eq!(reserve_res.status(), StatusCode::OK);
|
||||
let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON");
|
||||
let signing_key = reserve_body["signingKey"]
|
||||
.as_str()
|
||||
.expect("No signingKey returned");
|
||||
let public_key_multibase = signing_key
|
||||
.strip_prefix("did:key:")
|
||||
.expect("signingKey should start with did:key:");
|
||||
|
||||
let did_doc = json!({
|
||||
"@context": ["https://www.w3.org/ns/did/v1"],
|
||||
"id": did,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": public_key_multibase
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
@@ -213,7 +267,8 @@ async fn test_did_web_lifecycle() {
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password",
|
||||
"did": did
|
||||
"did": did,
|
||||
"signingKey": signing_key
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
|
||||
Reference in New Issue
Block a user