+22
.sqlx/query-0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b.json
+22
.sqlx/query-0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'registration' AND expires_at > NOW()\n ORDER BY created_at DESC\n LIMIT 1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "state_json",
9
+
"type_info": "Text"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "0e3540c274a021fb4f441027a9d5a0bbc0c2ba75977d44c5501831a828337e9b"
22
+
}
+76
.sqlx/query-23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12.json
+76
.sqlx/query-23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports\n FROM passkeys\n WHERE did = $1\n ORDER BY created_at DESC\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "credential_id",
19
+
"type_info": "Bytea"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "public_key",
24
+
"type_info": "Bytea"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "sign_count",
29
+
"type_info": "Int4"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "created_at",
34
+
"type_info": "Timestamptz"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
+
"name": "last_used",
39
+
"type_info": "Timestamptz"
40
+
},
41
+
{
42
+
"ordinal": 7,
43
+
"name": "friendly_name",
44
+
"type_info": "Text"
45
+
},
46
+
{
47
+
"ordinal": 8,
48
+
"name": "aaguid",
49
+
"type_info": "Bytea"
50
+
},
51
+
{
52
+
"ordinal": 9,
53
+
"name": "transports",
54
+
"type_info": "TextArray"
55
+
}
56
+
],
57
+
"parameters": {
58
+
"Left": [
59
+
"Text"
60
+
]
61
+
},
62
+
"nullable": [
63
+
false,
64
+
false,
65
+
false,
66
+
false,
67
+
false,
68
+
false,
69
+
true,
70
+
true,
71
+
true,
72
+
true
73
+
]
74
+
},
75
+
"hash": "23be24429e0ead3992c2035d10bd43d1c4f8614dbf60381bf847e002d41afc12"
76
+
}
+16
.sqlx/query-2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9.json
+16
.sqlx/query-2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)\n VALUES ($1, $2, $3, false, NOW())\n ON CONFLICT (did) DO UPDATE SET\n secret_encrypted = $2,\n encryption_version = $3,\n verified = false,\n created_at = NOW(),\n last_used = NULL\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Bytea",
10
+
"Int4"
11
+
]
12
+
},
13
+
"nullable": []
14
+
},
15
+
"hash": "2d92c719dca561ed37eb84cb5ce3f55ed4ff5b918de0165b9690fcaff3975cc9"
16
+
}
+12
.sqlx/query-2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614.json
+12
.sqlx/query-2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM webauthn_challenges WHERE expires_at < NOW()",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": []
8
+
},
9
+
"nullable": []
10
+
},
11
+
"hash": "2ec70c878be04feff4521059a96b6634d2b1a746222ec5cc41b69d12868cf614"
12
+
}
+34
.sqlx/query-2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d.json
+34
.sqlx/query-2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "secret_encrypted",
9
+
"type_info": "Bytea"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "encryption_version",
14
+
"type_info": "Int4"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "verified",
19
+
"type_info": "Bool"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
false
31
+
]
32
+
},
33
+
"hash": "2f675bf96916c9546b9dce1d0da71ba59256722b9750ec1da4747f3d82a2a00d"
34
+
}
+18
.sqlx/query-418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74.json
+18
.sqlx/query-418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'authentication', $4, $5)\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Text",
10
+
"Bytea",
11
+
"Text",
12
+
"Timestamptz"
13
+
]
14
+
},
15
+
"nullable": []
16
+
},
17
+
"hash": "418f04226f0306018517e44f80af924c435dbee0246662a36afa5cd40d674f74"
18
+
}
+14
.sqlx/query-41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147.json
+14
.sqlx/query-41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM user_totp WHERE did = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "41f936992d4d968d94fa77b07a24892bb6c9d5a96f28e6329aa7a3265bb31147"
14
+
}
+22
.sqlx/query-470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76.json
+22
.sqlx/query-470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "count",
9
+
"type_info": "Int8"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
null
19
+
]
20
+
},
21
+
"hash": "470411a450478dca72d99802e2f36173da716b17ed172f276ab3ae3608d79d76"
22
+
}
+19
.sqlx/query-4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb.json
+19
.sqlx/query-4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name, aaguid)\n VALUES ($1, $2, $3, $4, 0, $5, $6)\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Text",
10
+
"Bytea",
11
+
"Bytea",
12
+
"Text",
13
+
"Bytea"
14
+
]
15
+
},
16
+
"nullable": []
17
+
},
18
+
"hash": "4e13c8ab9350a3f4aa30fed13e2a27c11c8eb1af132fc9ac54d5b67b518186cb"
19
+
}
+46
.sqlx/query-513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81.json
+46
.sqlx/query-513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT two_factor_enabled, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", id FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "two_factor_enabled",
9
+
"type_info": "Bool"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "preferred_comms_channel: CommsChannel",
14
+
"type_info": {
15
+
"Custom": {
16
+
"name": "comms_channel",
17
+
"kind": {
18
+
"Enum": [
19
+
"email",
20
+
"discord",
21
+
"telegram",
22
+
"signal"
23
+
]
24
+
}
25
+
}
26
+
}
27
+
},
28
+
{
29
+
"ordinal": 2,
30
+
"name": "id",
31
+
"type_info": "Uuid"
32
+
}
33
+
],
34
+
"parameters": {
35
+
"Left": [
36
+
"Text"
37
+
]
38
+
},
39
+
"nullable": [
40
+
false,
41
+
false,
42
+
false
43
+
]
44
+
},
45
+
"hash": "513411270022d2761360a3226e6f46ce6296b5c647e2c7c8c46437c616545b81"
46
+
}
+14
.sqlx/query-6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0.json
+14
.sqlx/query-6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM backup_codes WHERE did = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "6952b39f2d82e97fb25f950192fa0c0257785f05d1d1b224826b90a71e59bce0"
14
+
}
+15
.sqlx/query-6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3.json
+15
.sqlx/query-6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Int4",
9
+
"Bytea"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "6d2b4fc7165cc2baeaafb29a09f9cdb3f34882fdec7e0398b306a7d00eac8aa3"
15
+
}
+22
.sqlx/query-76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0.json
+22
.sqlx/query-76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT state_json FROM webauthn_challenges\n WHERE did = $1 AND challenge_type = 'authentication' AND expires_at > NOW()\n ORDER BY created_at DESC\n LIMIT 1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "state_json",
9
+
"type_info": "Text"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "76700abdfe11a4152fe00729d02030c8617cb9d82c2a2bb26f6d9984bf19abc0"
22
+
}
+14
.sqlx/query-80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c.json
+14
.sqlx/query-80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'authentication'",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "80a11866a38b57fb2ce0347bcb2bed91c541376ebf1edc33f15b39ab5fef631c"
14
+
}
+22
.sqlx/query-a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338.json
+22
.sqlx/query-a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT did FROM users WHERE handle = $1 OR email = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "did",
9
+
"type_info": "Text"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "a36650b1da2c628957a2f00de442cd0e70a042ba80ad0c4ad31b1739f11a7338"
22
+
}
+76
.sqlx/query-aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3.json
+76
.sqlx/query-aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports\n FROM passkeys\n WHERE credential_id = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "credential_id",
19
+
"type_info": "Bytea"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "public_key",
24
+
"type_info": "Bytea"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "sign_count",
29
+
"type_info": "Int4"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "created_at",
34
+
"type_info": "Timestamptz"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
+
"name": "last_used",
39
+
"type_info": "Timestamptz"
40
+
},
41
+
{
42
+
"ordinal": 7,
43
+
"name": "friendly_name",
44
+
"type_info": "Text"
45
+
},
46
+
{
47
+
"ordinal": 8,
48
+
"name": "aaguid",
49
+
"type_info": "Bytea"
50
+
},
51
+
{
52
+
"ordinal": 9,
53
+
"name": "transports",
54
+
"type_info": "TextArray"
55
+
}
56
+
],
57
+
"parameters": {
58
+
"Left": [
59
+
"Bytea"
60
+
]
61
+
},
62
+
"nullable": [
63
+
false,
64
+
false,
65
+
false,
66
+
false,
67
+
false,
68
+
false,
69
+
true,
70
+
true,
71
+
true,
72
+
true
73
+
]
74
+
},
75
+
"hash": "aca13ec60c2d81d92b4e3008f981b48d091428b8f5a10dbaf97a6ca254a07fd3"
76
+
}
+14
.sqlx/query-b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a.json
+14
.sqlx/query-b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'registration'",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "b883a570154909b24df4dc2a4423ea5efc70ce91b8b841316e500dc97ee5df0a"
14
+
}
+22
.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
+22
.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT password_hash FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "password_hash",
9
+
"type_info": "Text"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745"
22
+
}
+28
.sqlx/query-cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a.json
+28
.sqlx/query-cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "code_hash",
14
+
"type_info": "Text"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Text"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false,
24
+
false
25
+
]
26
+
},
27
+
"hash": "cc72716ad4c54d40db10b7556496fb8806724139e33b229a08749391623b806a"
28
+
}
+14
.sqlx/query-d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b.json
+14
.sqlx/query-d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE user_totp SET last_used = NOW() WHERE did = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "d7dbe44f7015149f333b62eb3f79acb352cc4030fe13b49b4124cd7c7e9b360b"
14
+
}
+58
.sqlx/query-e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c.json
+58
.sqlx/query-e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT did, deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "did",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "deactivated_at",
14
+
"type_info": "Timestamptz"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "takedown_ref",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "email_verified",
24
+
"type_info": "Bool"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "discord_verified",
29
+
"type_info": "Bool"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "telegram_verified",
34
+
"type_info": "Bool"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
+
"name": "signal_verified",
39
+
"type_info": "Bool"
40
+
}
41
+
],
42
+
"parameters": {
43
+
"Left": [
44
+
"Text"
45
+
]
46
+
},
47
+
"nullable": [
48
+
false,
49
+
true,
50
+
true,
51
+
false,
52
+
false,
53
+
false,
54
+
false
55
+
]
56
+
},
57
+
"hash": "e1b969fe0a26533669b4bab5e3dfc9f01fe951a8485ab820a224ab4c76d0c45c"
58
+
}
+22
.sqlx/query-e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c.json
+22
.sqlx/query-e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT verified FROM user_totp WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "verified",
9
+
"type_info": "Bool"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "e670bdc9e1a3ee7f1ad04491d54e6caf56637669a91f8972c0d46a12c8a8b21c"
22
+
}
+15
.sqlx/query-e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9.json
+15
.sqlx/query-e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE backup_codes SET used_at = $1 WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Timestamptz",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "e94c76fd5d0a0cdf57db2c2eb4c10bddf39712adffcf9f5ea0c8399f4d39a7e9"
15
+
}
+15
.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json
+15
.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Text"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d"
15
+
}
+18
.sqlx/query-eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed.json
+18
.sqlx/query-eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)\n VALUES ($1, $2, $3, 'registration', $4, $5)\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Text",
10
+
"Bytea",
11
+
"Text",
12
+
"Timestamptz"
13
+
]
14
+
},
15
+
"nullable": []
16
+
},
17
+
"hash": "eb9c5129a82120747251e6311e20840d2557153e4b81393476a443f3d4e75fed"
18
+
}
+14
.sqlx/query-f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af.json
+14
.sqlx/query-f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "f2533a6aefb5e7449b90787d811297fa42ebae9c876c90f42ecf7b88b2f803af"
14
+
}
+227
Cargo.lock
+227
Cargo.lock
···
117
117
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
118
118
119
119
[[package]]
120
+
name = "asn1-rs"
121
+
version = "0.6.2"
122
+
source = "registry+https://github.com/rust-lang/crates.io-index"
123
+
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
124
+
dependencies = [
125
+
"asn1-rs-derive",
126
+
"asn1-rs-impl",
127
+
"displaydoc",
128
+
"nom",
129
+
"num-traits",
130
+
"rusticata-macros",
131
+
"thiserror 1.0.69",
132
+
"time",
133
+
]
134
+
135
+
[[package]]
136
+
name = "asn1-rs-derive"
137
+
version = "0.5.1"
138
+
source = "registry+https://github.com/rust-lang/crates.io-index"
139
+
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
140
+
dependencies = [
141
+
"proc-macro2",
142
+
"quote",
143
+
"syn 2.0.111",
144
+
"synstructure",
145
+
]
146
+
147
+
[[package]]
148
+
name = "asn1-rs-impl"
149
+
version = "0.2.0"
150
+
source = "registry+https://github.com/rust-lang/crates.io-index"
151
+
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
152
+
dependencies = [
153
+
"proc-macro2",
154
+
"quote",
155
+
"syn 2.0.111",
156
+
]
157
+
158
+
[[package]]
120
159
name = "assert-json-diff"
121
160
version = "2.0.2"
122
161
source = "registry+https://github.com/rust-lang/crates.io-index"
···
778
817
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
779
818
780
819
[[package]]
820
+
name = "base64urlsafedata"
821
+
version = "0.5.4"
822
+
source = "registry+https://github.com/rust-lang/crates.io-index"
823
+
checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a"
824
+
dependencies = [
825
+
"base64 0.21.7",
826
+
"pastey",
827
+
"serde",
828
+
]
829
+
830
+
[[package]]
781
831
name = "bcrypt"
782
832
version = "0.17.1"
783
833
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1217
1267
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
1218
1268
1219
1269
[[package]]
1270
+
name = "constant_time_eq"
1271
+
version = "0.3.1"
1272
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1273
+
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
1274
+
1275
+
[[package]]
1220
1276
name = "cordyceps"
1221
1277
version = "0.3.4"
1222
1278
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1561
1617
]
1562
1618
1563
1619
[[package]]
1620
+
name = "der-parser"
1621
+
version = "9.0.0"
1622
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1623
+
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
1624
+
dependencies = [
1625
+
"asn1-rs",
1626
+
"displaydoc",
1627
+
"nom",
1628
+
"num-bigint",
1629
+
"num-traits",
1630
+
"rusticata-macros",
1631
+
]
1632
+
1633
+
[[package]]
1564
1634
name = "deranged"
1565
1635
version = "0.5.5"
1566
1636
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3873
3943
]
3874
3944
3875
3945
[[package]]
3946
+
name = "oid-registry"
3947
+
version = "0.7.1"
3948
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3949
+
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
3950
+
dependencies = [
3951
+
"asn1-rs",
3952
+
]
3953
+
3954
+
[[package]]
3876
3955
name = "once_cell"
3877
3956
version = "1.21.3"
3878
3957
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4046
4125
"structmeta",
4047
4126
"syn 2.0.111",
4048
4127
]
4128
+
4129
+
[[package]]
4130
+
name = "pastey"
4131
+
version = "0.1.1"
4132
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4133
+
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
4049
4134
4050
4135
[[package]]
4051
4136
name = "pem"
···
4358
4443
]
4359
4444
4360
4445
[[package]]
4446
+
name = "qrcodegen"
4447
+
version = "1.8.0"
4448
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4449
+
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
4450
+
4451
+
[[package]]
4452
+
name = "qrcodegen-image"
4453
+
version = "1.5.0"
4454
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4455
+
checksum = "221b7eace1aef8c95d65dbe09fb7a1a43d006045394a89afba6997721fcb7708"
4456
+
dependencies = [
4457
+
"base64 0.22.1",
4458
+
"image",
4459
+
"qrcodegen",
4460
+
]
4461
+
4462
+
[[package]]
4361
4463
name = "quanta"
4362
4464
version = "0.12.6"
4363
4465
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4746
4848
]
4747
4849
4748
4850
[[package]]
4851
+
name = "rusticata-macros"
4852
+
version = "4.1.0"
4853
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4854
+
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
4855
+
dependencies = [
4856
+
"nom",
4857
+
]
4858
+
4859
+
[[package]]
4749
4860
name = "rustix"
4750
4861
version = "1.1.2"
4751
4862
source = "registry+https://github.com/rust-lang/crates.io-index"
···
5037
5148
]
5038
5149
5039
5150
[[package]]
5151
+
name = "serde_cbor_2"
5152
+
version = "0.13.0"
5153
+
source = "registry+https://github.com/rust-lang/crates.io-index"
5154
+
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
5155
+
dependencies = [
5156
+
"half",
5157
+
"serde",
5158
+
]
5159
+
5160
+
[[package]]
5040
5161
name = "serde_core"
5041
5162
version = "1.0.228"
5042
5163
source = "registry+https://github.com/rust-lang/crates.io-index"
···
6040
6161
]
6041
6162
6042
6163
[[package]]
6164
+
name = "totp-rs"
6165
+
version = "5.7.0"
6166
+
source = "registry+https://github.com/rust-lang/crates.io-index"
6167
+
checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72"
6168
+
dependencies = [
6169
+
"base32",
6170
+
"constant_time_eq",
6171
+
"hmac",
6172
+
"qrcodegen-image",
6173
+
"sha1",
6174
+
"sha2",
6175
+
"url",
6176
+
"urlencoding",
6177
+
]
6178
+
6179
+
[[package]]
6043
6180
name = "tower"
6044
6181
version = "0.5.2"
6045
6182
source = "registry+https://github.com/rust-lang/crates.io-index"
···
6226
6363
"thiserror 2.0.17",
6227
6364
"tokio",
6228
6365
"tokio-tungstenite",
6366
+
"totp-rs",
6229
6367
"tower-http",
6230
6368
"tracing",
6231
6369
"tracing-subscriber",
6232
6370
"urlencoding",
6233
6371
"uuid",
6372
+
"webauthn-rs",
6373
+
"webauthn-rs-proto",
6234
6374
"wiremock",
6235
6375
]
6236
6376
···
6416
6556
"getrandom 0.3.4",
6417
6557
"js-sys",
6418
6558
"rand 0.9.2",
6559
+
"serde_core",
6560
+
"sha1_smol",
6419
6561
"wasm-bindgen",
6420
6562
]
6421
6563
···
6575
6717
]
6576
6718
6577
6719
[[package]]
6720
+
name = "webauthn-attestation-ca"
6721
+
version = "0.5.4"
6722
+
source = "registry+https://github.com/rust-lang/crates.io-index"
6723
+
checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b"
6724
+
dependencies = [
6725
+
"base64urlsafedata",
6726
+
"openssl",
6727
+
"openssl-sys",
6728
+
"serde",
6729
+
"tracing",
6730
+
"uuid",
6731
+
]
6732
+
6733
+
[[package]]
6734
+
name = "webauthn-rs"
6735
+
version = "0.5.4"
6736
+
source = "registry+https://github.com/rust-lang/crates.io-index"
6737
+
checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8"
6738
+
dependencies = [
6739
+
"base64urlsafedata",
6740
+
"serde",
6741
+
"tracing",
6742
+
"url",
6743
+
"uuid",
6744
+
"webauthn-rs-core",
6745
+
]
6746
+
6747
+
[[package]]
6748
+
name = "webauthn-rs-core"
6749
+
version = "0.5.4"
6750
+
source = "registry+https://github.com/rust-lang/crates.io-index"
6751
+
checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
6752
+
dependencies = [
6753
+
"base64 0.21.7",
6754
+
"base64urlsafedata",
6755
+
"der-parser",
6756
+
"hex",
6757
+
"nom",
6758
+
"openssl",
6759
+
"openssl-sys",
6760
+
"rand 0.9.2",
6761
+
"rand_chacha 0.9.0",
6762
+
"serde",
6763
+
"serde_cbor_2",
6764
+
"serde_json",
6765
+
"thiserror 1.0.69",
6766
+
"tracing",
6767
+
"url",
6768
+
"uuid",
6769
+
"webauthn-attestation-ca",
6770
+
"webauthn-rs-proto",
6771
+
"x509-parser",
6772
+
]
6773
+
6774
+
[[package]]
6775
+
name = "webauthn-rs-proto"
6776
+
version = "0.5.4"
6777
+
source = "registry+https://github.com/rust-lang/crates.io-index"
6778
+
checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c"
6779
+
dependencies = [
6780
+
"base64 0.21.7",
6781
+
"base64urlsafedata",
6782
+
"serde",
6783
+
"serde_json",
6784
+
"url",
6785
+
]
6786
+
6787
+
[[package]]
6578
6788
name = "webpage"
6579
6789
version = "2.0.1"
6580
6790
source = "registry+https://github.com/rust-lang/crates.io-index"
···
7082
7292
version = "0.6.2"
7083
7293
source = "registry+https://github.com/rust-lang/crates.io-index"
7084
7294
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
7295
+
7296
+
[[package]]
7297
+
name = "x509-parser"
7298
+
version = "0.16.0"
7299
+
source = "registry+https://github.com/rust-lang/crates.io-index"
7300
+
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
7301
+
dependencies = [
7302
+
"asn1-rs",
7303
+
"data-encoding",
7304
+
"der-parser",
7305
+
"lazy_static",
7306
+
"nom",
7307
+
"oid-registry",
7308
+
"rusticata-macros",
7309
+
"thiserror 1.0.69",
7310
+
"time",
7311
+
]
7085
7312
7086
7313
[[package]]
7087
7314
name = "xattr"
+4
-1
Cargo.toml
+4
-1
Cargo.toml
···
47
47
tracing-subscriber = "0.3.22"
48
48
tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] }
49
49
urlencoding = "2.1"
50
-
uuid = { version = "1.19.0", features = ["v4", "fast-rng"] }
50
+
uuid = { version = "1.19.0", features = ["v4", "v5", "fast-rng"] }
51
51
iroh-car = "0.5.1"
52
52
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
53
53
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
···
56
56
metrics = "0.24"
57
57
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
58
58
bs58 = "0.5.1"
59
+
totp-rs = { version = "5", features = ["qr"] }
60
+
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
61
+
webauthn-rs-proto = "0.5.4"
59
62
[features]
60
63
external-infra = []
61
64
[dev-dependencies]
+6
frontend/src/App.svelte
+6
frontend/src/App.svelte
···
17
17
import OAuthLogin from './routes/OAuthLogin.svelte'
18
18
import OAuthAccounts from './routes/OAuthAccounts.svelte'
19
19
import OAuth2FA from './routes/OAuth2FA.svelte'
20
+
import OAuthTotp from './routes/OAuthTotp.svelte'
20
21
import OAuthError from './routes/OAuthError.svelte'
22
+
import Security from './routes/Security.svelte'
21
23
22
24
const auth = getAuthState()
23
25
···
59
61
return OAuthAccounts
60
62
case '/oauth/2fa':
61
63
return OAuth2FA
64
+
case '/oauth/totp':
65
+
return OAuthTotp
62
66
case '/oauth/error':
63
67
return OAuthError
68
+
case '/security':
69
+
return Security
64
70
default:
65
71
return auth.session ? Dashboard : Login
66
72
}
+76
frontend/src/lib/api.ts
+76
frontend/src/lib/api.ts
···
493
493
body: { repo, collection, rkey },
494
494
})
495
495
},
496
+
497
+
async getTotpStatus(token: string): Promise<{ enabled: boolean; hasBackupCodes: boolean }> {
498
+
return xrpc('com.atproto.server.getTotpStatus', { token })
499
+
},
500
+
501
+
async createTotpSecret(token: string): Promise<{ uri: string; qrBase64: string }> {
502
+
return xrpc('com.atproto.server.createTotpSecret', { method: 'POST', token })
503
+
},
504
+
505
+
async enableTotp(token: string, code: string): Promise<{ success: boolean; backupCodes: string[] }> {
506
+
return xrpc('com.atproto.server.enableTotp', {
507
+
method: 'POST',
508
+
token,
509
+
body: { code },
510
+
})
511
+
},
512
+
513
+
async disableTotp(token: string, password: string, code: string): Promise<{ success: boolean }> {
514
+
return xrpc('com.atproto.server.disableTotp', {
515
+
method: 'POST',
516
+
token,
517
+
body: { password, code },
518
+
})
519
+
},
520
+
521
+
async regenerateBackupCodes(token: string, password: string, code: string): Promise<{ backupCodes: string[] }> {
522
+
return xrpc('com.atproto.server.regenerateBackupCodes', {
523
+
method: 'POST',
524
+
token,
525
+
body: { password, code },
526
+
})
527
+
},
528
+
529
+
async startPasskeyRegistration(token: string, friendlyName?: string): Promise<{ options: unknown }> {
530
+
return xrpc('com.atproto.server.startPasskeyRegistration', {
531
+
method: 'POST',
532
+
token,
533
+
body: { friendlyName },
534
+
})
535
+
},
536
+
537
+
async finishPasskeyRegistration(token: string, credential: unknown, friendlyName?: string): Promise<{ id: string; credentialId: string }> {
538
+
return xrpc('com.atproto.server.finishPasskeyRegistration', {
539
+
method: 'POST',
540
+
token,
541
+
body: { credential, friendlyName },
542
+
})
543
+
},
544
+
545
+
async listPasskeys(token: string): Promise<{
546
+
passkeys: Array<{
547
+
id: string
548
+
credentialId: string
549
+
friendlyName: string | null
550
+
createdAt: string
551
+
lastUsed: string | null
552
+
}>
553
+
}> {
554
+
return xrpc('com.atproto.server.listPasskeys', { token })
555
+
},
556
+
557
+
async deletePasskey(token: string, id: string): Promise<void> {
558
+
await xrpc('com.atproto.server.deletePasskey', {
559
+
method: 'POST',
560
+
token,
561
+
body: { id },
562
+
})
563
+
},
564
+
565
+
async updatePasskey(token: string, id: string, friendlyName: string): Promise<void> {
566
+
await xrpc('com.atproto.server.updatePasskey', {
567
+
method: 'POST',
568
+
token,
569
+
body: { id, friendlyName },
570
+
})
571
+
},
496
572
}
+4
frontend/src/routes/Dashboard.svelte
+4
frontend/src/routes/Dashboard.svelte
···
155
155
<h3>Account Settings</h3>
156
156
<p>Email, password, handle, and more</p>
157
157
</a>
158
+
<a href="#/security" class="nav-card">
159
+
<h3>Security</h3>
160
+
<p>Two-factor authentication</p>
161
+
</a>
158
162
<a href="#/notifications" class="nav-card">
159
163
<h3>Notification Preferences</h3>
160
164
<p>Discord, Telegram, Signal channels</p>
+5
frontend/src/routes/OAuthAccounts.svelte
+5
frontend/src/routes/OAuthAccounts.svelte
···
73
73
return
74
74
}
75
75
76
+
if (data.needs_totp) {
77
+
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
78
+
return
79
+
}
80
+
76
81
if (data.needs_2fa) {
77
82
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
78
83
return
+308
-1
frontend/src/routes/OAuthLogin.svelte
+308
-1
frontend/src/routes/OAuthLogin.svelte
···
6
6
let rememberDevice = $state(false)
7
7
let submitting = $state(false)
8
8
let error = $state<string | null>(null)
9
+
let hasPasskeys = $state(false)
10
+
let hasTotp = $state(false)
11
+
let checkingSecurityStatus = $state(false)
12
+
let securityStatusChecked = $state(false)
13
+
let passkeySupported = $state(false)
14
+
let clientName = $state<string | null>(null)
15
+
16
+
$effect(() => {
17
+
passkeySupported = window.PublicKeyCredential !== undefined
18
+
})
9
19
10
20
function getRequestUri(): string | null {
11
21
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
···
24
34
}
25
35
})
26
36
37
+
$effect(() => {
38
+
fetchAuthRequestInfo()
39
+
})
40
+
41
+
async function fetchAuthRequestInfo() {
42
+
const requestUri = getRequestUri()
43
+
if (!requestUri) return
44
+
45
+
try {
46
+
const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, {
47
+
headers: { 'Accept': 'application/json' }
48
+
})
49
+
if (response.ok) {
50
+
const data = await response.json()
51
+
if (data.login_hint && !username) {
52
+
username = data.login_hint
53
+
}
54
+
if (data.client_name) {
55
+
clientName = data.client_name
56
+
}
57
+
}
58
+
} catch {
59
+
// Ignore errors fetching auth info
60
+
}
61
+
}
62
+
63
+
let checkTimeout: ReturnType<typeof setTimeout> | null = null
64
+
65
+
$effect(() => {
66
+
if (checkTimeout) {
67
+
clearTimeout(checkTimeout)
68
+
}
69
+
hasPasskeys = false
70
+
hasTotp = false
71
+
securityStatusChecked = false
72
+
if (username.length >= 3) {
73
+
checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500)
74
+
}
75
+
})
76
+
77
+
async function checkUserSecurityStatus() {
78
+
if (!username || checkingSecurityStatus) return
79
+
checkingSecurityStatus = true
80
+
try {
81
+
const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`)
82
+
if (response.ok) {
83
+
const data = await response.json()
84
+
hasPasskeys = passkeySupported && data.hasPasskeys === true
85
+
hasTotp = data.hasTotp === true
86
+
securityStatusChecked = true
87
+
}
88
+
} catch {
89
+
hasPasskeys = false
90
+
hasTotp = false
91
+
} finally {
92
+
checkingSecurityStatus = false
93
+
}
94
+
}
95
+
96
+
97
+
async function handlePasskeyLogin() {
98
+
const requestUri = getRequestUri()
99
+
if (!requestUri || !username) {
100
+
error = 'Missing required parameters'
101
+
return
102
+
}
103
+
104
+
submitting = true
105
+
error = null
106
+
107
+
try {
108
+
const startResponse = await fetch('/oauth/passkey/start', {
109
+
method: 'POST',
110
+
headers: {
111
+
'Content-Type': 'application/json',
112
+
'Accept': 'application/json'
113
+
},
114
+
body: JSON.stringify({
115
+
request_uri: requestUri,
116
+
identifier: username
117
+
})
118
+
})
119
+
120
+
if (!startResponse.ok) {
121
+
const data = await startResponse.json()
122
+
error = data.error_description || data.error || 'Failed to start passkey login'
123
+
submitting = false
124
+
return
125
+
}
126
+
127
+
const { options } = await startResponse.json()
128
+
129
+
const credential = await navigator.credentials.get({
130
+
publicKey: prepareCredentialRequestOptions(options.publicKey)
131
+
}) as PublicKeyCredential | null
132
+
133
+
if (!credential) {
134
+
error = 'Passkey authentication was cancelled'
135
+
submitting = false
136
+
return
137
+
}
138
+
139
+
const assertionResponse = credential.response as AuthenticatorAssertionResponse
140
+
const credentialData = {
141
+
id: credential.id,
142
+
type: credential.type,
143
+
rawId: arrayBufferToBase64Url(credential.rawId),
144
+
response: {
145
+
clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON),
146
+
authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData),
147
+
signature: arrayBufferToBase64Url(assertionResponse.signature),
148
+
userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null
149
+
}
150
+
}
151
+
152
+
const finishResponse = await fetch('/oauth/passkey/finish', {
153
+
method: 'POST',
154
+
headers: {
155
+
'Content-Type': 'application/json',
156
+
'Accept': 'application/json'
157
+
},
158
+
body: JSON.stringify({
159
+
request_uri: requestUri,
160
+
credential: credentialData
161
+
})
162
+
})
163
+
164
+
const data = await finishResponse.json()
165
+
166
+
if (!finishResponse.ok) {
167
+
error = data.error_description || data.error || 'Passkey authentication failed'
168
+
submitting = false
169
+
return
170
+
}
171
+
172
+
if (data.needs_totp) {
173
+
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
174
+
return
175
+
}
176
+
177
+
if (data.needs_2fa) {
178
+
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
179
+
return
180
+
}
181
+
182
+
if (data.redirect_uri) {
183
+
window.location.href = data.redirect_uri
184
+
return
185
+
}
186
+
187
+
error = 'Unexpected response from server'
188
+
submitting = false
189
+
} catch (e) {
190
+
console.error('Passkey login error:', e)
191
+
if (e instanceof DOMException && e.name === 'NotAllowedError') {
192
+
error = 'Passkey authentication was cancelled'
193
+
} else {
194
+
error = `Failed to authenticate with passkey: ${e instanceof Error ? e.message : String(e)}`
195
+
}
196
+
submitting = false
197
+
}
198
+
}
199
+
200
+
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
201
+
const bytes = new Uint8Array(buffer)
202
+
let binary = ''
203
+
for (let i = 0; i < bytes.byteLength; i++) {
204
+
binary += String.fromCharCode(bytes[i])
205
+
}
206
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
207
+
}
208
+
209
+
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
210
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
211
+
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
212
+
const binary = atob(padded)
213
+
const bytes = new Uint8Array(binary.length)
214
+
for (let i = 0; i < binary.length; i++) {
215
+
bytes[i] = binary.charCodeAt(i)
216
+
}
217
+
return bytes.buffer
218
+
}
219
+
220
+
function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions {
221
+
return {
222
+
...options,
223
+
challenge: base64UrlToArrayBuffer(options.challenge),
224
+
allowCredentials: options.allowCredentials?.map((cred: any) => ({
225
+
...cred,
226
+
id: base64UrlToArrayBuffer(cred.id)
227
+
})) || []
228
+
}
229
+
}
230
+
27
231
async function handleSubmit(e: Event) {
28
232
e.preventDefault()
29
233
const requestUri = getRequestUri()
···
55
259
if (!response.ok) {
56
260
error = data.error_description || data.error || 'Login failed'
57
261
submitting = false
262
+
return
263
+
}
264
+
265
+
if (data.needs_totp) {
266
+
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
58
267
return
59
268
}
60
269
···
106
315
107
316
<div class="oauth-login-container">
108
317
<h1>Sign In</h1>
109
-
<p class="subtitle">Sign in to continue to the application</p>
318
+
<p class="subtitle">
319
+
{#if clientName}
320
+
Sign in to continue to <strong>{clientName}</strong>
321
+
{:else}
322
+
Sign in to continue to the application
323
+
{/if}
324
+
</p>
110
325
111
326
{#if error}
112
327
<div class="error">{error}</div>
···
126
341
/>
127
342
</div>
128
343
344
+
{#if securityStatusChecked && passkeySupported}
345
+
<button
346
+
type="button"
347
+
class="passkey-btn"
348
+
class:passkey-unavailable={!hasPasskeys}
349
+
onclick={handlePasskeyLogin}
350
+
disabled={submitting || !hasPasskeys || !username}
351
+
title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'}
352
+
>
353
+
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
354
+
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
355
+
<path d="M17 17v4l3-2-3-2z" />
356
+
<path d="M12 11c-4 0-6 2-6 4v4h9" />
357
+
</svg>
358
+
<span class="passkey-text">
359
+
{#if submitting}
360
+
Authenticating...
361
+
{:else if hasPasskeys}
362
+
Sign in with passkey
363
+
{:else}
364
+
Passkey not set up
365
+
{/if}
366
+
</span>
367
+
</button>
368
+
369
+
<div class="auth-divider">
370
+
<span>or use password</span>
371
+
</div>
372
+
{/if}
373
+
129
374
<div class="field">
130
375
<label for="password">Password</label>
131
376
<input
···
265
510
266
511
.submit-btn:hover:not(:disabled) {
267
512
background: var(--accent-hover);
513
+
}
514
+
515
+
.auth-divider {
516
+
display: flex;
517
+
align-items: center;
518
+
gap: 1rem;
519
+
margin: 0.5rem 0;
520
+
}
521
+
522
+
.auth-divider::before,
523
+
.auth-divider::after {
524
+
content: '';
525
+
flex: 1;
526
+
height: 1px;
527
+
background: var(--border-color-light);
528
+
}
529
+
530
+
.auth-divider span {
531
+
color: var(--text-secondary);
532
+
font-size: 0.875rem;
533
+
}
534
+
535
+
.passkey-btn {
536
+
display: flex;
537
+
align-items: center;
538
+
justify-content: center;
539
+
gap: 0.5rem;
540
+
width: 100%;
541
+
padding: 0.75rem;
542
+
background: var(--accent);
543
+
color: white;
544
+
border: 1px solid var(--accent);
545
+
border-radius: 4px;
546
+
font-size: 1rem;
547
+
cursor: pointer;
548
+
transition: background-color 0.15s, border-color 0.15s, opacity 0.15s;
549
+
}
550
+
551
+
.passkey-btn:hover:not(:disabled) {
552
+
background: var(--accent-hover);
553
+
border-color: var(--accent-hover);
554
+
}
555
+
556
+
.passkey-btn:disabled {
557
+
opacity: 0.6;
558
+
cursor: not-allowed;
559
+
}
560
+
561
+
.passkey-btn.passkey-unavailable {
562
+
background: var(--bg-secondary);
563
+
color: var(--text-secondary);
564
+
border-color: var(--border-color);
565
+
}
566
+
567
+
.passkey-icon {
568
+
width: 20px;
569
+
height: 20px;
570
+
}
571
+
572
+
.passkey-text {
573
+
flex: 1;
574
+
text-align: left;
268
575
}
269
576
</style>
+225
frontend/src/routes/OAuthTotp.svelte
+225
frontend/src/routes/OAuthTotp.svelte
···
1
+
<script lang="ts">
2
+
import { navigate } from '../lib/router.svelte'
3
+
4
+
let code = $state('')
5
+
let submitting = $state(false)
6
+
let error = $state<string | null>(null)
7
+
8
+
function getRequestUri(): string | null {
9
+
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
10
+
return params.get('request_uri')
11
+
}
12
+
13
+
async function handleSubmit(e: Event) {
14
+
e.preventDefault()
15
+
const requestUri = getRequestUri()
16
+
if (!requestUri) {
17
+
error = 'Missing request_uri parameter'
18
+
return
19
+
}
20
+
21
+
submitting = true
22
+
error = null
23
+
24
+
try {
25
+
const response = await fetch('/oauth/authorize/2fa', {
26
+
method: 'POST',
27
+
headers: {
28
+
'Content-Type': 'application/json',
29
+
'Accept': 'application/json'
30
+
},
31
+
body: JSON.stringify({
32
+
request_uri: requestUri,
33
+
code: code.trim().toUpperCase()
34
+
})
35
+
})
36
+
37
+
const data = await response.json()
38
+
39
+
if (!response.ok) {
40
+
error = data.error_description || data.error || 'Verification failed'
41
+
submitting = false
42
+
return
43
+
}
44
+
45
+
if (data.redirect_uri) {
46
+
window.location.href = data.redirect_uri
47
+
return
48
+
}
49
+
50
+
error = 'Unexpected response from server'
51
+
submitting = false
52
+
} catch {
53
+
error = 'Failed to connect to server'
54
+
submitting = false
55
+
}
56
+
}
57
+
58
+
function handleCancel() {
59
+
const requestUri = getRequestUri()
60
+
if (requestUri) {
61
+
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
62
+
} else {
63
+
window.history.back()
64
+
}
65
+
}
66
+
67
+
let isBackupCode = $derived(code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim()))
68
+
let isTotpCode = $derived(code.trim().length === 6 && /^[0-9]+$/.test(code.trim()))
69
+
let canSubmit = $derived(isBackupCode || isTotpCode)
70
+
</script>
71
+
72
+
<div class="oauth-totp-container">
73
+
<h1>Two-Factor Authentication</h1>
74
+
<p class="subtitle">
75
+
Enter the 6-digit code from your authenticator app, or use a backup code.
76
+
</p>
77
+
78
+
{#if error}
79
+
<div class="error">{error}</div>
80
+
{/if}
81
+
82
+
<form onsubmit={handleSubmit}>
83
+
<div class="field">
84
+
<label for="code">Verification Code</label>
85
+
<input
86
+
id="code"
87
+
type="text"
88
+
bind:value={code}
89
+
placeholder="Enter code"
90
+
disabled={submitting}
91
+
required
92
+
maxlength="8"
93
+
autocomplete="one-time-code"
94
+
autocapitalize="characters"
95
+
/>
96
+
<p class="hint">
97
+
{#if isBackupCode}
98
+
Using backup code
99
+
{:else if isTotpCode}
100
+
Using authenticator code
101
+
{:else}
102
+
6 digits for authenticator, 8 characters for backup code
103
+
{/if}
104
+
</p>
105
+
</div>
106
+
107
+
<div class="actions">
108
+
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
109
+
Cancel
110
+
</button>
111
+
<button type="submit" class="submit-btn" disabled={submitting || !canSubmit}>
112
+
{submitting ? 'Verifying...' : 'Verify'}
113
+
</button>
114
+
</div>
115
+
</form>
116
+
</div>
117
+
118
+
<style>
119
+
.oauth-totp-container {
120
+
max-width: 400px;
121
+
margin: 4rem auto;
122
+
padding: 2rem;
123
+
}
124
+
125
+
h1 {
126
+
margin: 0 0 0.5rem 0;
127
+
}
128
+
129
+
.subtitle {
130
+
color: var(--text-secondary);
131
+
margin: 0 0 2rem 0;
132
+
}
133
+
134
+
form {
135
+
display: flex;
136
+
flex-direction: column;
137
+
gap: 1rem;
138
+
}
139
+
140
+
.field {
141
+
display: flex;
142
+
flex-direction: column;
143
+
gap: 0.25rem;
144
+
}
145
+
146
+
label {
147
+
font-size: 0.875rem;
148
+
font-weight: 500;
149
+
}
150
+
151
+
input {
152
+
padding: 0.75rem;
153
+
border: 1px solid var(--border-color-light);
154
+
border-radius: 4px;
155
+
font-size: 1.5rem;
156
+
letter-spacing: 0.25em;
157
+
text-align: center;
158
+
background: var(--bg-input);
159
+
color: var(--text-primary);
160
+
text-transform: uppercase;
161
+
}
162
+
163
+
input:focus {
164
+
outline: none;
165
+
border-color: var(--accent);
166
+
}
167
+
168
+
.hint {
169
+
font-size: 0.75rem;
170
+
color: var(--text-muted);
171
+
margin: 0.25rem 0 0 0;
172
+
text-align: center;
173
+
}
174
+
175
+
.error {
176
+
padding: 0.75rem;
177
+
background: var(--error-bg);
178
+
border: 1px solid var(--error-border);
179
+
border-radius: 4px;
180
+
color: var(--error-text);
181
+
margin-bottom: 1rem;
182
+
}
183
+
184
+
.actions {
185
+
display: flex;
186
+
gap: 1rem;
187
+
margin-top: 0.5rem;
188
+
}
189
+
190
+
.actions button {
191
+
flex: 1;
192
+
padding: 0.75rem;
193
+
border: none;
194
+
border-radius: 4px;
195
+
font-size: 1rem;
196
+
cursor: pointer;
197
+
transition: background-color 0.15s;
198
+
}
199
+
200
+
.actions button:disabled {
201
+
opacity: 0.6;
202
+
cursor: not-allowed;
203
+
}
204
+
205
+
.cancel-btn {
206
+
background: var(--bg-secondary);
207
+
color: var(--text-primary);
208
+
border: 1px solid var(--border-color);
209
+
}
210
+
211
+
.cancel-btn:hover:not(:disabled) {
212
+
background: var(--error-bg);
213
+
border-color: var(--error-border);
214
+
color: var(--error-text);
215
+
}
216
+
217
+
.submit-btn {
218
+
background: var(--accent);
219
+
color: white;
220
+
}
221
+
222
+
.submit-btn:hover:not(:disabled) {
223
+
background: var(--accent-hover);
224
+
}
225
+
</style>
+897
frontend/src/routes/Security.svelte
+897
frontend/src/routes/Security.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, ApiError } from '../lib/api'
5
+
6
+
const auth = getAuthState()
7
+
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
8
+
let loading = $state(true)
9
+
let totpEnabled = $state(false)
10
+
let hasBackupCodes = $state(false)
11
+
let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle')
12
+
let qrBase64 = $state('')
13
+
let totpUri = $state('')
14
+
let verifyCodeRaw = $state('')
15
+
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
16
+
let verifyLoading = $state(false)
17
+
let backupCodes = $state<string[]>([])
18
+
let disablePassword = $state('')
19
+
let disableCode = $state('')
20
+
let disableLoading = $state(false)
21
+
let showDisableForm = $state(false)
22
+
let regenPassword = $state('')
23
+
let regenCode = $state('')
24
+
let regenLoading = $state(false)
25
+
let showRegenForm = $state(false)
26
+
27
+
interface Passkey {
28
+
id: string
29
+
credentialId: string
30
+
friendlyName: string | null
31
+
createdAt: string
32
+
lastUsed: string | null
33
+
}
34
+
let passkeys = $state<Passkey[]>([])
35
+
let passkeysLoading = $state(true)
36
+
let addingPasskey = $state(false)
37
+
let newPasskeyName = $state('')
38
+
let editingPasskeyId = $state<string | null>(null)
39
+
let editPasskeyName = $state('')
40
+
41
+
$effect(() => {
42
+
if (!auth.loading && !auth.session) {
43
+
navigate('/login')
44
+
}
45
+
})
46
+
47
+
$effect(() => {
48
+
if (auth.session) {
49
+
loadTotpStatus()
50
+
loadPasskeys()
51
+
}
52
+
})
53
+
54
+
async function loadTotpStatus() {
55
+
if (!auth.session) return
56
+
loading = true
57
+
try {
58
+
const status = await api.getTotpStatus(auth.session.accessJwt)
59
+
totpEnabled = status.enabled
60
+
hasBackupCodes = status.hasBackupCodes
61
+
} catch {
62
+
showMessage('error', 'Failed to load TOTP status')
63
+
} finally {
64
+
loading = false
65
+
}
66
+
}
67
+
68
+
function showMessage(type: 'success' | 'error', text: string) {
69
+
message = { type, text }
70
+
setTimeout(() => {
71
+
if (message?.text === text) message = null
72
+
}, 5000)
73
+
}
74
+
75
+
async function handleStartSetup() {
76
+
if (!auth.session) return
77
+
verifyLoading = true
78
+
try {
79
+
const result = await api.createTotpSecret(auth.session.accessJwt)
80
+
qrBase64 = result.qrBase64
81
+
totpUri = result.uri
82
+
setupStep = 'qr'
83
+
} catch (e) {
84
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
85
+
} finally {
86
+
verifyLoading = false
87
+
}
88
+
}
89
+
90
+
async function handleVerifySetup(e: Event) {
91
+
e.preventDefault()
92
+
if (!auth.session || !verifyCode) return
93
+
verifyLoading = true
94
+
try {
95
+
const result = await api.enableTotp(auth.session.accessJwt, verifyCode)
96
+
backupCodes = result.backupCodes
97
+
setupStep = 'backup'
98
+
totpEnabled = true
99
+
hasBackupCodes = true
100
+
verifyCodeRaw = ''
101
+
} catch (e) {
102
+
showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
103
+
} finally {
104
+
verifyLoading = false
105
+
}
106
+
}
107
+
108
+
function handleFinishSetup() {
109
+
setupStep = 'idle'
110
+
backupCodes = []
111
+
qrBase64 = ''
112
+
totpUri = ''
113
+
showMessage('success', 'Two-factor authentication enabled successfully')
114
+
}
115
+
116
+
async function handleDisable(e: Event) {
117
+
e.preventDefault()
118
+
if (!auth.session || !disablePassword || !disableCode) return
119
+
disableLoading = true
120
+
try {
121
+
await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode)
122
+
totpEnabled = false
123
+
hasBackupCodes = false
124
+
showDisableForm = false
125
+
disablePassword = ''
126
+
disableCode = ''
127
+
showMessage('success', 'Two-factor authentication disabled')
128
+
} catch (e) {
129
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP')
130
+
} finally {
131
+
disableLoading = false
132
+
}
133
+
}
134
+
135
+
async function handleRegenerate(e: Event) {
136
+
e.preventDefault()
137
+
if (!auth.session || !regenPassword || !regenCode) return
138
+
regenLoading = true
139
+
try {
140
+
const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode)
141
+
backupCodes = result.backupCodes
142
+
setupStep = 'backup'
143
+
showRegenForm = false
144
+
regenPassword = ''
145
+
regenCode = ''
146
+
} catch (e) {
147
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
148
+
} finally {
149
+
regenLoading = false
150
+
}
151
+
}
152
+
153
+
function copyBackupCodes() {
154
+
const text = backupCodes.join('\n')
155
+
navigator.clipboard.writeText(text)
156
+
showMessage('success', 'Backup codes copied to clipboard')
157
+
}
158
+
159
+
async function loadPasskeys() {
160
+
if (!auth.session) return
161
+
passkeysLoading = true
162
+
try {
163
+
const result = await api.listPasskeys(auth.session.accessJwt)
164
+
passkeys = result.passkeys
165
+
} catch {
166
+
showMessage('error', 'Failed to load passkeys')
167
+
} finally {
168
+
passkeysLoading = false
169
+
}
170
+
}
171
+
172
+
async function handleAddPasskey() {
173
+
if (!auth.session) return
174
+
if (!window.PublicKeyCredential) {
175
+
showMessage('error', 'Passkeys are not supported in this browser')
176
+
return
177
+
}
178
+
addingPasskey = true
179
+
try {
180
+
const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined)
181
+
const publicKeyOptions = preparePublicKeyOptions(options)
182
+
const credential = await navigator.credentials.create({
183
+
publicKey: publicKeyOptions
184
+
})
185
+
if (!credential) {
186
+
showMessage('error', 'Passkey creation was cancelled')
187
+
return
188
+
}
189
+
const credentialResponse = {
190
+
id: credential.id,
191
+
type: credential.type,
192
+
rawId: arrayBufferToBase64Url((credential as PublicKeyCredential).rawId),
193
+
response: {
194
+
clientDataJSON: arrayBufferToBase64Url((credential as PublicKeyCredential).response.clientDataJSON),
195
+
attestationObject: arrayBufferToBase64Url(((credential as PublicKeyCredential).response as AuthenticatorAttestationResponse).attestationObject),
196
+
},
197
+
}
198
+
await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined)
199
+
await loadPasskeys()
200
+
newPasskeyName = ''
201
+
showMessage('success', 'Passkey added successfully')
202
+
} catch (e) {
203
+
if (e instanceof DOMException && e.name === 'NotAllowedError') {
204
+
showMessage('error', 'Passkey creation was cancelled')
205
+
} else {
206
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey')
207
+
}
208
+
} finally {
209
+
addingPasskey = false
210
+
}
211
+
}
212
+
213
+
async function handleDeletePasskey(id: string) {
214
+
if (!auth.session) return
215
+
if (!confirm('Are you sure you want to delete this passkey?')) return
216
+
try {
217
+
await api.deletePasskey(auth.session.accessJwt, id)
218
+
await loadPasskeys()
219
+
showMessage('success', 'Passkey deleted')
220
+
} catch (e) {
221
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey')
222
+
}
223
+
}
224
+
225
+
async function handleSavePasskeyName() {
226
+
if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return
227
+
try {
228
+
await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim())
229
+
await loadPasskeys()
230
+
editingPasskeyId = null
231
+
editPasskeyName = ''
232
+
showMessage('success', 'Passkey renamed')
233
+
} catch (e) {
234
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey')
235
+
}
236
+
}
237
+
238
+
function startEditPasskey(passkey: Passkey) {
239
+
editingPasskeyId = passkey.id
240
+
editPasskeyName = passkey.friendlyName || ''
241
+
}
242
+
243
+
function cancelEditPasskey() {
244
+
editingPasskeyId = null
245
+
editPasskeyName = ''
246
+
}
247
+
248
+
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
249
+
const bytes = new Uint8Array(buffer)
250
+
let binary = ''
251
+
for (let i = 0; i < bytes.byteLength; i++) {
252
+
binary += String.fromCharCode(bytes[i])
253
+
}
254
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
255
+
}
256
+
257
+
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
258
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
259
+
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
260
+
const binary = atob(padded)
261
+
const bytes = new Uint8Array(binary.length)
262
+
for (let i = 0; i < binary.length; i++) {
263
+
bytes[i] = binary.charCodeAt(i)
264
+
}
265
+
return bytes.buffer
266
+
}
267
+
268
+
function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions {
269
+
return {
270
+
...options.publicKey,
271
+
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
272
+
user: {
273
+
...options.publicKey.user,
274
+
id: base64UrlToArrayBuffer(options.publicKey.user.id)
275
+
},
276
+
excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({
277
+
...cred,
278
+
id: base64UrlToArrayBuffer(cred.id)
279
+
})) || []
280
+
}
281
+
}
282
+
283
+
function formatDate(dateStr: string): string {
284
+
return new Date(dateStr).toLocaleDateString()
285
+
}
286
+
</script>
287
+
288
+
<div class="page">
289
+
<header>
290
+
<a href="#/dashboard" class="back">← Dashboard</a>
291
+
<h1>Security Settings</h1>
292
+
</header>
293
+
294
+
{#if message}
295
+
<div class="message {message.type}">{message.text}</div>
296
+
{/if}
297
+
298
+
{#if loading}
299
+
<div class="loading">Loading...</div>
300
+
{:else}
301
+
<section>
302
+
<h2>Two-Factor Authentication</h2>
303
+
<p class="description">
304
+
Add an extra layer of security to your account using an authenticator app like Google Authenticator, Authy, or 1Password.
305
+
</p>
306
+
307
+
{#if setupStep === 'idle'}
308
+
{#if totpEnabled}
309
+
<div class="status enabled">
310
+
<span>Two-factor authentication is <strong>enabled</strong></span>
311
+
</div>
312
+
313
+
{#if !showDisableForm && !showRegenForm}
314
+
<div class="totp-actions">
315
+
<button type="button" class="secondary" onclick={() => showRegenForm = true}>
316
+
Regenerate Backup Codes
317
+
</button>
318
+
<button type="button" class="danger-outline" onclick={() => showDisableForm = true}>
319
+
Disable 2FA
320
+
</button>
321
+
</div>
322
+
{/if}
323
+
324
+
{#if showRegenForm}
325
+
<form onsubmit={handleRegenerate} class="inline-form">
326
+
<h3>Regenerate Backup Codes</h3>
327
+
<p class="warning-text">This will invalidate all existing backup codes.</p>
328
+
<div class="field">
329
+
<label for="regen-password">Password</label>
330
+
<input
331
+
id="regen-password"
332
+
type="password"
333
+
bind:value={regenPassword}
334
+
placeholder="Enter your password"
335
+
disabled={regenLoading}
336
+
required
337
+
/>
338
+
</div>
339
+
<div class="field">
340
+
<label for="regen-code">Authenticator Code</label>
341
+
<input
342
+
id="regen-code"
343
+
type="text"
344
+
bind:value={regenCode}
345
+
placeholder="6-digit code"
346
+
disabled={regenLoading}
347
+
required
348
+
maxlength="6"
349
+
pattern="[0-9]{6}"
350
+
inputmode="numeric"
351
+
/>
352
+
</div>
353
+
<div class="actions">
354
+
<button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}>
355
+
Cancel
356
+
</button>
357
+
<button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}>
358
+
{regenLoading ? 'Regenerating...' : 'Regenerate'}
359
+
</button>
360
+
</div>
361
+
</form>
362
+
{/if}
363
+
364
+
{#if showDisableForm}
365
+
<form onsubmit={handleDisable} class="inline-form danger-form">
366
+
<h3>Disable Two-Factor Authentication</h3>
367
+
<p class="warning-text">This will make your account less secure.</p>
368
+
<div class="field">
369
+
<label for="disable-password">Password</label>
370
+
<input
371
+
id="disable-password"
372
+
type="password"
373
+
bind:value={disablePassword}
374
+
placeholder="Enter your password"
375
+
disabled={disableLoading}
376
+
required
377
+
/>
378
+
</div>
379
+
<div class="field">
380
+
<label for="disable-code">Authenticator Code</label>
381
+
<input
382
+
id="disable-code"
383
+
type="text"
384
+
bind:value={disableCode}
385
+
placeholder="6-digit code"
386
+
disabled={disableLoading}
387
+
required
388
+
maxlength="6"
389
+
pattern="[0-9]{6}"
390
+
inputmode="numeric"
391
+
/>
392
+
</div>
393
+
<div class="actions">
394
+
<button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}>
395
+
Cancel
396
+
</button>
397
+
<button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}>
398
+
{disableLoading ? 'Disabling...' : 'Disable 2FA'}
399
+
</button>
400
+
</div>
401
+
</form>
402
+
{/if}
403
+
{:else}
404
+
<div class="status disabled">
405
+
<span>Two-factor authentication is <strong>not enabled</strong></span>
406
+
</div>
407
+
<button onclick={handleStartSetup} disabled={verifyLoading}>
408
+
{verifyLoading ? 'Setting up...' : 'Set Up Two-Factor Authentication'}
409
+
</button>
410
+
{/if}
411
+
{:else if setupStep === 'qr'}
412
+
<div class="setup-step">
413
+
<h3>Step 1: Scan QR Code</h3>
414
+
<p>Scan this QR code with your authenticator app:</p>
415
+
<div class="qr-container">
416
+
<img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" />
417
+
</div>
418
+
<details class="manual-entry">
419
+
<summary>Can't scan? Enter manually</summary>
420
+
<code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
421
+
</details>
422
+
<button onclick={() => setupStep = 'verify'}>
423
+
Next: Verify Code
424
+
</button>
425
+
</div>
426
+
{:else if setupStep === 'verify'}
427
+
<div class="setup-step">
428
+
<h3>Step 2: Verify Setup</h3>
429
+
<p>Enter the 6-digit code from your authenticator app:</p>
430
+
<form onsubmit={handleVerifySetup}>
431
+
<div class="field">
432
+
<input
433
+
type="text"
434
+
bind:value={verifyCodeRaw}
435
+
placeholder="000000"
436
+
disabled={verifyLoading}
437
+
inputmode="numeric"
438
+
class="code-input"
439
+
/>
440
+
</div>
441
+
<div class="actions">
442
+
<button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}>
443
+
Back
444
+
</button>
445
+
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>
446
+
{verifyLoading ? 'Verifying...' : 'Verify & Enable'}
447
+
</button>
448
+
</div>
449
+
</form>
450
+
</div>
451
+
{:else if setupStep === 'backup'}
452
+
<div class="setup-step">
453
+
<h3>Step 3: Save Backup Codes</h3>
454
+
<p class="warning-text">
455
+
Save these backup codes in a secure location. Each code can only be used once.
456
+
If you lose access to your authenticator app, you'll need these to sign in.
457
+
</p>
458
+
<div class="backup-codes">
459
+
{#each backupCodes as code}
460
+
<code class="backup-code">{code}</code>
461
+
{/each}
462
+
</div>
463
+
<div class="actions">
464
+
<button type="button" class="secondary" onclick={copyBackupCodes}>
465
+
Copy to Clipboard
466
+
</button>
467
+
<button onclick={handleFinishSetup}>
468
+
I've Saved My Codes
469
+
</button>
470
+
</div>
471
+
</div>
472
+
{/if}
473
+
</section>
474
+
475
+
<section>
476
+
<h2>Passkeys</h2>
477
+
<p class="description">
478
+
Passkeys are a secure, passwordless way to sign in using biometrics (fingerprint or face), a security key, or your device's screen lock.
479
+
</p>
480
+
481
+
{#if passkeysLoading}
482
+
<div class="loading">Loading passkeys...</div>
483
+
{:else}
484
+
{#if passkeys.length > 0}
485
+
<div class="passkey-list">
486
+
{#each passkeys as passkey}
487
+
<div class="passkey-item">
488
+
{#if editingPasskeyId === passkey.id}
489
+
<div class="passkey-edit">
490
+
<input
491
+
type="text"
492
+
bind:value={editPasskeyName}
493
+
placeholder="Passkey name"
494
+
class="passkey-name-input"
495
+
/>
496
+
<div class="passkey-edit-actions">
497
+
<button type="button" class="small" onclick={handleSavePasskeyName}>Save</button>
498
+
<button type="button" class="small secondary" onclick={cancelEditPasskey}>Cancel</button>
499
+
</div>
500
+
</div>
501
+
{:else}
502
+
<div class="passkey-info">
503
+
<span class="passkey-name">{passkey.friendlyName || 'Unnamed passkey'}</span>
504
+
<span class="passkey-meta">
505
+
Added {formatDate(passkey.createdAt)}
506
+
{#if passkey.lastUsed}
507
+
· Last used {formatDate(passkey.lastUsed)}
508
+
{/if}
509
+
</span>
510
+
</div>
511
+
<div class="passkey-actions">
512
+
<button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}>
513
+
Rename
514
+
</button>
515
+
<button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
516
+
Delete
517
+
</button>
518
+
</div>
519
+
{/if}
520
+
</div>
521
+
{/each}
522
+
</div>
523
+
{:else}
524
+
<div class="status disabled">
525
+
<span>No passkeys registered</span>
526
+
</div>
527
+
{/if}
528
+
529
+
<div class="add-passkey">
530
+
<div class="field">
531
+
<label for="passkey-name">Passkey Name (optional)</label>
532
+
<input
533
+
id="passkey-name"
534
+
type="text"
535
+
bind:value={newPasskeyName}
536
+
placeholder="e.g., MacBook Touch ID"
537
+
disabled={addingPasskey}
538
+
/>
539
+
</div>
540
+
<button onclick={handleAddPasskey} disabled={addingPasskey}>
541
+
{addingPasskey ? 'Adding Passkey...' : 'Add a Passkey'}
542
+
</button>
543
+
</div>
544
+
{/if}
545
+
</section>
546
+
{/if}
547
+
</div>
548
+
549
+
<style>
550
+
.page {
551
+
max-width: 600px;
552
+
margin: 0 auto;
553
+
padding: 2rem;
554
+
}
555
+
556
+
header {
557
+
margin-bottom: 2rem;
558
+
}
559
+
560
+
.back {
561
+
color: var(--text-secondary);
562
+
text-decoration: none;
563
+
font-size: 0.875rem;
564
+
}
565
+
566
+
.back:hover {
567
+
color: var(--accent);
568
+
}
569
+
570
+
h1 {
571
+
margin: 0.5rem 0 0 0;
572
+
}
573
+
574
+
.message {
575
+
padding: 0.75rem;
576
+
border-radius: 4px;
577
+
margin-bottom: 1rem;
578
+
}
579
+
580
+
.message.success {
581
+
background: var(--success-bg);
582
+
border: 1px solid var(--success-border);
583
+
color: var(--success-text);
584
+
}
585
+
586
+
.message.error {
587
+
background: var(--error-bg);
588
+
border: 1px solid var(--error-border);
589
+
color: var(--error-text);
590
+
}
591
+
592
+
.loading {
593
+
text-align: center;
594
+
color: var(--text-secondary);
595
+
padding: 2rem;
596
+
}
597
+
598
+
section {
599
+
padding: 1.5rem;
600
+
background: var(--bg-secondary);
601
+
border-radius: 8px;
602
+
margin-bottom: 1.5rem;
603
+
}
604
+
605
+
section h2 {
606
+
margin: 0 0 0.5rem 0;
607
+
font-size: 1.125rem;
608
+
}
609
+
610
+
.description {
611
+
color: var(--text-secondary);
612
+
font-size: 0.875rem;
613
+
margin-bottom: 1.5rem;
614
+
}
615
+
616
+
.status {
617
+
display: flex;
618
+
align-items: center;
619
+
gap: 0.5rem;
620
+
padding: 0.75rem;
621
+
border-radius: 4px;
622
+
margin-bottom: 1rem;
623
+
}
624
+
625
+
.status.enabled {
626
+
background: var(--success-bg);
627
+
border: 1px solid var(--success-border);
628
+
color: var(--success-text);
629
+
}
630
+
631
+
.status.disabled {
632
+
background: var(--warning-bg);
633
+
border: 1px solid var(--border-color);
634
+
color: var(--warning-text);
635
+
}
636
+
637
+
.totp-actions {
638
+
display: flex;
639
+
gap: 0.5rem;
640
+
flex-wrap: wrap;
641
+
}
642
+
643
+
.field {
644
+
margin-bottom: 1rem;
645
+
}
646
+
647
+
label {
648
+
display: block;
649
+
font-size: 0.875rem;
650
+
font-weight: 500;
651
+
margin-bottom: 0.25rem;
652
+
}
653
+
654
+
input {
655
+
width: 100%;
656
+
padding: 0.75rem;
657
+
border: 1px solid var(--border-color-light);
658
+
border-radius: 4px;
659
+
font-size: 1rem;
660
+
box-sizing: border-box;
661
+
background: var(--bg-input);
662
+
color: var(--text-primary);
663
+
}
664
+
665
+
input:focus {
666
+
outline: none;
667
+
border-color: var(--accent);
668
+
}
669
+
670
+
.code-input {
671
+
font-size: 1.5rem;
672
+
letter-spacing: 0.5em;
673
+
text-align: center;
674
+
max-width: 200px;
675
+
margin: 0 auto;
676
+
display: block;
677
+
}
678
+
679
+
button {
680
+
padding: 0.75rem 1.5rem;
681
+
background: var(--accent);
682
+
color: white;
683
+
border: none;
684
+
border-radius: 4px;
685
+
cursor: pointer;
686
+
font-size: 1rem;
687
+
}
688
+
689
+
button:hover:not(:disabled) {
690
+
background: var(--accent-hover);
691
+
}
692
+
693
+
button:disabled {
694
+
opacity: 0.6;
695
+
cursor: not-allowed;
696
+
}
697
+
698
+
button.secondary {
699
+
background: transparent;
700
+
color: var(--text-secondary);
701
+
border: 1px solid var(--border-color-light);
702
+
}
703
+
704
+
button.secondary:hover:not(:disabled) {
705
+
background: var(--bg-card);
706
+
}
707
+
708
+
button.danger {
709
+
background: var(--error-text);
710
+
}
711
+
712
+
button.danger:hover:not(:disabled) {
713
+
background: #900;
714
+
}
715
+
716
+
button.danger-outline {
717
+
background: transparent;
718
+
color: var(--error-text);
719
+
border: 1px solid var(--error-border);
720
+
}
721
+
722
+
button.danger-outline:hover:not(:disabled) {
723
+
background: var(--error-bg);
724
+
}
725
+
726
+
.actions {
727
+
display: flex;
728
+
gap: 0.5rem;
729
+
margin-top: 1rem;
730
+
}
731
+
732
+
.inline-form {
733
+
margin-top: 1rem;
734
+
padding: 1rem;
735
+
background: var(--bg-card);
736
+
border: 1px solid var(--border-color-light);
737
+
border-radius: 6px;
738
+
}
739
+
740
+
.inline-form h3 {
741
+
margin: 0 0 0.5rem 0;
742
+
font-size: 1rem;
743
+
}
744
+
745
+
.danger-form {
746
+
border-color: var(--error-border);
747
+
background: var(--error-bg);
748
+
}
749
+
750
+
.warning-text {
751
+
color: var(--error-text);
752
+
font-size: 0.875rem;
753
+
margin-bottom: 1rem;
754
+
}
755
+
756
+
.setup-step {
757
+
padding: 1rem;
758
+
background: var(--bg-card);
759
+
border: 1px solid var(--border-color-light);
760
+
border-radius: 6px;
761
+
}
762
+
763
+
.setup-step h3 {
764
+
margin: 0 0 0.5rem 0;
765
+
}
766
+
767
+
.setup-step p {
768
+
color: var(--text-secondary);
769
+
font-size: 0.875rem;
770
+
margin-bottom: 1rem;
771
+
}
772
+
773
+
.qr-container {
774
+
display: flex;
775
+
justify-content: center;
776
+
margin: 1.5rem 0;
777
+
}
778
+
779
+
.qr-code {
780
+
width: 200px;
781
+
height: 200px;
782
+
image-rendering: pixelated;
783
+
}
784
+
785
+
.manual-entry {
786
+
margin-bottom: 1rem;
787
+
font-size: 0.875rem;
788
+
}
789
+
790
+
.manual-entry summary {
791
+
cursor: pointer;
792
+
color: var(--accent);
793
+
}
794
+
795
+
.secret-code {
796
+
display: block;
797
+
margin-top: 0.5rem;
798
+
padding: 0.5rem;
799
+
background: var(--bg-input);
800
+
border-radius: 4px;
801
+
word-break: break-all;
802
+
font-size: 0.75rem;
803
+
}
804
+
805
+
.backup-codes {
806
+
display: grid;
807
+
grid-template-columns: repeat(2, 1fr);
808
+
gap: 0.5rem;
809
+
margin: 1rem 0;
810
+
}
811
+
812
+
.backup-code {
813
+
padding: 0.5rem;
814
+
background: var(--bg-input);
815
+
border-radius: 4px;
816
+
text-align: center;
817
+
font-size: 0.875rem;
818
+
font-family: monospace;
819
+
}
820
+
821
+
.passkey-list {
822
+
display: flex;
823
+
flex-direction: column;
824
+
gap: 0.5rem;
825
+
margin-bottom: 1rem;
826
+
}
827
+
828
+
.passkey-item {
829
+
display: flex;
830
+
justify-content: space-between;
831
+
align-items: center;
832
+
padding: 0.75rem;
833
+
background: var(--bg-card);
834
+
border: 1px solid var(--border-color-light);
835
+
border-radius: 6px;
836
+
gap: 1rem;
837
+
}
838
+
839
+
.passkey-info {
840
+
display: flex;
841
+
flex-direction: column;
842
+
gap: 0.25rem;
843
+
flex: 1;
844
+
min-width: 0;
845
+
}
846
+
847
+
.passkey-name {
848
+
font-weight: 500;
849
+
overflow: hidden;
850
+
text-overflow: ellipsis;
851
+
white-space: nowrap;
852
+
}
853
+
854
+
.passkey-meta {
855
+
font-size: 0.75rem;
856
+
color: var(--text-secondary);
857
+
}
858
+
859
+
.passkey-actions {
860
+
display: flex;
861
+
gap: 0.5rem;
862
+
flex-shrink: 0;
863
+
}
864
+
865
+
.passkey-edit {
866
+
display: flex;
867
+
flex: 1;
868
+
gap: 0.5rem;
869
+
align-items: center;
870
+
}
871
+
872
+
.passkey-name-input {
873
+
flex: 1;
874
+
padding: 0.5rem;
875
+
font-size: 0.875rem;
876
+
}
877
+
878
+
.passkey-edit-actions {
879
+
display: flex;
880
+
gap: 0.25rem;
881
+
}
882
+
883
+
button.small {
884
+
padding: 0.375rem 0.75rem;
885
+
font-size: 0.75rem;
886
+
}
887
+
888
+
.add-passkey {
889
+
margin-top: 1rem;
890
+
padding-top: 1rem;
891
+
border-top: 1px solid var(--border-color-light);
892
+
}
893
+
894
+
.add-passkey .field {
895
+
margin-bottom: 0.75rem;
896
+
}
897
+
</style>
+42
migrations/20251223_add_passkeys_totp.sql
+42
migrations/20251223_add_passkeys_totp.sql
···
1
+
CREATE TABLE user_totp (
2
+
did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE,
3
+
secret_encrypted BYTEA NOT NULL,
4
+
encryption_version INTEGER NOT NULL DEFAULT 1,
5
+
verified BOOLEAN NOT NULL DEFAULT FALSE,
6
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7
+
last_used TIMESTAMPTZ
8
+
);
9
+
10
+
CREATE TABLE backup_codes (
11
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
12
+
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
13
+
code_hash TEXT NOT NULL,
14
+
used_at TIMESTAMPTZ,
15
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
16
+
);
17
+
CREATE INDEX idx_backup_codes_did ON backup_codes(did);
18
+
19
+
CREATE TABLE passkeys (
20
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
21
+
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
22
+
credential_id BYTEA NOT NULL UNIQUE,
23
+
public_key BYTEA NOT NULL,
24
+
sign_count INTEGER NOT NULL DEFAULT 0,
25
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
26
+
last_used TIMESTAMPTZ,
27
+
friendly_name TEXT,
28
+
aaguid BYTEA,
29
+
transports TEXT[]
30
+
);
31
+
CREATE INDEX idx_passkeys_did ON passkeys(did);
32
+
33
+
CREATE TABLE webauthn_challenges (
34
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
35
+
did TEXT NOT NULL,
36
+
challenge BYTEA NOT NULL,
37
+
challenge_type TEXT NOT NULL,
38
+
state_json TEXT NOT NULL,
39
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
40
+
expires_at TIMESTAMPTZ NOT NULL
41
+
);
42
+
CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
+10
src/api/server/mod.rs
+10
src/api/server/mod.rs
···
3
3
pub mod email;
4
4
pub mod invite;
5
5
pub mod meta;
6
+
pub mod passkeys;
6
7
pub mod password;
7
8
pub mod service_auth;
8
9
pub mod session;
9
10
pub mod signing_key;
11
+
pub mod totp;
10
12
11
13
pub use account_status::{
12
14
activate_account, check_account_status, deactivate_account, delete_account,
···
16
18
pub use email::{confirm_email, request_email_update, update_email};
17
19
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
18
20
pub use meta::{describe_server, health, robots_txt};
21
+
pub use passkeys::{
22
+
delete_passkey, finish_passkey_registration, has_passkeys_for_user, list_passkeys,
23
+
start_passkey_registration, update_passkey,
24
+
};
19
25
pub use password::{change_password, request_password_reset, reset_password};
20
26
pub use service_auth::get_service_auth;
21
27
pub use session::{
···
23
29
resend_verification, revoke_session,
24
30
};
25
31
pub use signing_key::reserve_signing_key;
32
+
pub use totp::{
33
+
create_totp_secret, disable_totp, enable_totp, get_totp_status, has_totp_enabled,
34
+
regenerate_backup_codes, verify_totp_or_backup_for_user,
35
+
};
+377
src/api/server/passkeys.rs
+377
src/api/server/passkeys.rs
···
1
+
use crate::auth::BearerAuth;
2
+
use crate::auth::webauthn::{
3
+
self, WebAuthnConfig, delete_passkey as db_delete_passkey, delete_registration_state,
4
+
get_passkeys_for_user, load_registration_state, save_passkey, save_registration_state,
5
+
update_passkey_name as db_update_passkey_name,
6
+
};
7
+
use crate::state::AppState;
8
+
use axum::{
9
+
Json,
10
+
extract::State,
11
+
http::StatusCode,
12
+
response::{IntoResponse, Response},
13
+
};
14
+
use serde::{Deserialize, Serialize};
15
+
use serde_json::json;
16
+
use tracing::{error, info, warn};
17
+
use webauthn_rs::prelude::*;
18
+
19
+
fn get_webauthn() -> Result<WebAuthnConfig, (StatusCode, Json<serde_json::Value>)> {
20
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
21
+
WebAuthnConfig::new(&hostname).map_err(|e| {
22
+
error!("Failed to create WebAuthn config: {}", e);
23
+
(
24
+
StatusCode::INTERNAL_SERVER_ERROR,
25
+
Json(json!({"error": "InternalError", "message": "WebAuthn configuration failed"})),
26
+
)
27
+
})
28
+
}
29
+
30
+
#[derive(Deserialize)]
31
+
#[serde(rename_all = "camelCase")]
32
+
pub struct StartRegistrationInput {
33
+
pub friendly_name: Option<String>,
34
+
}
35
+
36
+
#[derive(Serialize)]
37
+
#[serde(rename_all = "camelCase")]
38
+
pub struct StartRegistrationResponse {
39
+
pub options: serde_json::Value,
40
+
}
41
+
42
+
pub async fn start_passkey_registration(
43
+
State(state): State<AppState>,
44
+
auth: BearerAuth,
45
+
Json(input): Json<StartRegistrationInput>,
46
+
) -> Response {
47
+
let webauthn = match get_webauthn() {
48
+
Ok(w) => w,
49
+
Err(e) => return e.into_response(),
50
+
};
51
+
52
+
let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", auth.0.did)
53
+
.fetch_optional(&state.db)
54
+
.await;
55
+
56
+
let handle = match user {
57
+
Ok(Some(row)) => row.handle,
58
+
Ok(None) => {
59
+
return (
60
+
StatusCode::NOT_FOUND,
61
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
62
+
)
63
+
.into_response();
64
+
}
65
+
Err(e) => {
66
+
error!("DB error fetching user: {:?}", e);
67
+
return (
68
+
StatusCode::INTERNAL_SERVER_ERROR,
69
+
Json(json!({"error": "InternalError"})),
70
+
)
71
+
.into_response();
72
+
}
73
+
};
74
+
75
+
let existing_passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await {
76
+
Ok(passkeys) => passkeys,
77
+
Err(e) => {
78
+
error!("DB error fetching existing passkeys: {:?}", e);
79
+
return (
80
+
StatusCode::INTERNAL_SERVER_ERROR,
81
+
Json(json!({"error": "InternalError"})),
82
+
)
83
+
.into_response();
84
+
}
85
+
};
86
+
87
+
let exclude_credentials: Vec<CredentialID> = existing_passkeys
88
+
.iter()
89
+
.map(|p| CredentialID::from(p.credential_id.clone()))
90
+
.collect();
91
+
92
+
let display_name = input.friendly_name.as_deref().unwrap_or(&handle);
93
+
94
+
let (ccr, reg_state) = match webauthn.start_registration(
95
+
&auth.0.did,
96
+
&handle,
97
+
display_name,
98
+
exclude_credentials,
99
+
) {
100
+
Ok(result) => result,
101
+
Err(e) => {
102
+
error!("Failed to start passkey registration: {}", e);
103
+
return (
104
+
StatusCode::INTERNAL_SERVER_ERROR,
105
+
Json(json!({"error": "InternalError", "message": "Failed to start registration"})),
106
+
)
107
+
.into_response();
108
+
}
109
+
};
110
+
111
+
if let Err(e) = save_registration_state(&state.db, &auth.0.did, ®_state).await {
112
+
error!("Failed to save registration state: {:?}", e);
113
+
return (
114
+
StatusCode::INTERNAL_SERVER_ERROR,
115
+
Json(json!({"error": "InternalError"})),
116
+
)
117
+
.into_response();
118
+
}
119
+
120
+
let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
121
+
122
+
info!(did = %auth.0.did, "Passkey registration started");
123
+
124
+
Json(StartRegistrationResponse { options }).into_response()
125
+
}
126
+
127
+
#[derive(Deserialize)]
128
+
#[serde(rename_all = "camelCase")]
129
+
pub struct FinishRegistrationInput {
130
+
pub credential: serde_json::Value,
131
+
pub friendly_name: Option<String>,
132
+
}
133
+
134
+
#[derive(Serialize)]
135
+
#[serde(rename_all = "camelCase")]
136
+
pub struct FinishRegistrationResponse {
137
+
pub id: String,
138
+
pub credential_id: String,
139
+
}
140
+
141
+
pub async fn finish_passkey_registration(
142
+
State(state): State<AppState>,
143
+
auth: BearerAuth,
144
+
Json(input): Json<FinishRegistrationInput>,
145
+
) -> Response {
146
+
let webauthn = match get_webauthn() {
147
+
Ok(w) => w,
148
+
Err(e) => return e.into_response(),
149
+
};
150
+
151
+
let reg_state = match load_registration_state(&state.db, &auth.0.did).await {
152
+
Ok(Some(state)) => state,
153
+
Ok(None) => {
154
+
return (
155
+
StatusCode::BAD_REQUEST,
156
+
Json(json!({
157
+
"error": "NoRegistrationInProgress",
158
+
"message": "No registration in progress. Call startPasskeyRegistration first."
159
+
})),
160
+
)
161
+
.into_response();
162
+
}
163
+
Err(e) => {
164
+
error!("DB error loading registration state: {:?}", e);
165
+
return (
166
+
StatusCode::INTERNAL_SERVER_ERROR,
167
+
Json(json!({"error": "InternalError"})),
168
+
)
169
+
.into_response();
170
+
}
171
+
};
172
+
173
+
let credential: RegisterPublicKeyCredential = match serde_json::from_value(input.credential) {
174
+
Ok(c) => c,
175
+
Err(e) => {
176
+
warn!("Failed to parse credential: {:?}", e);
177
+
return (
178
+
StatusCode::BAD_REQUEST,
179
+
Json(json!({
180
+
"error": "InvalidCredential",
181
+
"message": "Failed to parse credential response"
182
+
})),
183
+
)
184
+
.into_response();
185
+
}
186
+
};
187
+
188
+
let passkey = match webauthn.finish_registration(&credential, ®_state) {
189
+
Ok(pk) => pk,
190
+
Err(e) => {
191
+
warn!("Failed to finish passkey registration: {}", e);
192
+
return (
193
+
StatusCode::BAD_REQUEST,
194
+
Json(json!({
195
+
"error": "RegistrationFailed",
196
+
"message": "Failed to verify passkey registration"
197
+
})),
198
+
)
199
+
.into_response();
200
+
}
201
+
};
202
+
203
+
let passkey_id = match save_passkey(
204
+
&state.db,
205
+
&auth.0.did,
206
+
&passkey,
207
+
input.friendly_name.as_deref(),
208
+
)
209
+
.await
210
+
{
211
+
Ok(id) => id,
212
+
Err(e) => {
213
+
error!("Failed to save passkey: {:?}", e);
214
+
return (
215
+
StatusCode::INTERNAL_SERVER_ERROR,
216
+
Json(json!({"error": "InternalError"})),
217
+
)
218
+
.into_response();
219
+
}
220
+
};
221
+
222
+
if let Err(e) = delete_registration_state(&state.db, &auth.0.did).await {
223
+
warn!("Failed to delete registration state: {:?}", e);
224
+
}
225
+
226
+
let credential_id_base64 = base64::Engine::encode(
227
+
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
228
+
passkey.cred_id(),
229
+
);
230
+
231
+
info!(did = %auth.0.did, passkey_id = %passkey_id, "Passkey registered");
232
+
233
+
Json(FinishRegistrationResponse {
234
+
id: passkey_id.to_string(),
235
+
credential_id: credential_id_base64,
236
+
})
237
+
.into_response()
238
+
}
239
+
240
+
#[derive(Serialize)]
241
+
#[serde(rename_all = "camelCase")]
242
+
pub struct PasskeyInfo {
243
+
pub id: String,
244
+
pub credential_id: String,
245
+
pub friendly_name: Option<String>,
246
+
pub created_at: String,
247
+
pub last_used: Option<String>,
248
+
}
249
+
250
+
#[derive(Serialize)]
251
+
#[serde(rename_all = "camelCase")]
252
+
pub struct ListPasskeysResponse {
253
+
pub passkeys: Vec<PasskeyInfo>,
254
+
}
255
+
256
+
pub async fn list_passkeys(State(state): State<AppState>, auth: BearerAuth) -> Response {
257
+
let passkeys = match get_passkeys_for_user(&state.db, &auth.0.did).await {
258
+
Ok(pks) => pks,
259
+
Err(e) => {
260
+
error!("DB error fetching passkeys: {:?}", e);
261
+
return (
262
+
StatusCode::INTERNAL_SERVER_ERROR,
263
+
Json(json!({"error": "InternalError"})),
264
+
)
265
+
.into_response();
266
+
}
267
+
};
268
+
269
+
let passkey_infos: Vec<PasskeyInfo> = passkeys
270
+
.into_iter()
271
+
.map(|pk| PasskeyInfo {
272
+
id: pk.id.to_string(),
273
+
credential_id: pk.credential_id_base64(),
274
+
friendly_name: pk.friendly_name,
275
+
created_at: pk.created_at.to_rfc3339(),
276
+
last_used: pk.last_used.map(|dt| dt.to_rfc3339()),
277
+
})
278
+
.collect();
279
+
280
+
Json(ListPasskeysResponse {
281
+
passkeys: passkey_infos,
282
+
})
283
+
.into_response()
284
+
}
285
+
286
+
#[derive(Deserialize)]
287
+
#[serde(rename_all = "camelCase")]
288
+
pub struct DeletePasskeyInput {
289
+
pub id: String,
290
+
}
291
+
292
+
pub async fn delete_passkey(
293
+
State(state): State<AppState>,
294
+
auth: BearerAuth,
295
+
Json(input): Json<DeletePasskeyInput>,
296
+
) -> Response {
297
+
let id: uuid::Uuid = match input.id.parse() {
298
+
Ok(id) => id,
299
+
Err(_) => {
300
+
return (
301
+
StatusCode::BAD_REQUEST,
302
+
Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
303
+
)
304
+
.into_response();
305
+
}
306
+
};
307
+
308
+
match db_delete_passkey(&state.db, id, &auth.0.did).await {
309
+
Ok(true) => {
310
+
info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted");
311
+
(StatusCode::OK, Json(json!({}))).into_response()
312
+
}
313
+
Ok(false) => (
314
+
StatusCode::NOT_FOUND,
315
+
Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
316
+
)
317
+
.into_response(),
318
+
Err(e) => {
319
+
error!("DB error deleting passkey: {:?}", e);
320
+
(
321
+
StatusCode::INTERNAL_SERVER_ERROR,
322
+
Json(json!({"error": "InternalError"})),
323
+
)
324
+
.into_response()
325
+
}
326
+
}
327
+
}
328
+
329
+
#[derive(Deserialize)]
330
+
#[serde(rename_all = "camelCase")]
331
+
pub struct UpdatePasskeyInput {
332
+
pub id: String,
333
+
pub friendly_name: String,
334
+
}
335
+
336
+
pub async fn update_passkey(
337
+
State(state): State<AppState>,
338
+
auth: BearerAuth,
339
+
Json(input): Json<UpdatePasskeyInput>,
340
+
) -> Response {
341
+
let id: uuid::Uuid = match input.id.parse() {
342
+
Ok(id) => id,
343
+
Err(_) => {
344
+
return (
345
+
StatusCode::BAD_REQUEST,
346
+
Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
347
+
)
348
+
.into_response();
349
+
}
350
+
};
351
+
352
+
match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await {
353
+
Ok(true) => {
354
+
info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed");
355
+
(StatusCode::OK, Json(json!({}))).into_response()
356
+
}
357
+
Ok(false) => (
358
+
StatusCode::NOT_FOUND,
359
+
Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
360
+
)
361
+
.into_response(),
362
+
Err(e) => {
363
+
error!("DB error updating passkey: {:?}", e);
364
+
(
365
+
StatusCode::INTERNAL_SERVER_ERROR,
366
+
Json(json!({"error": "InternalError"})),
367
+
)
368
+
.into_response()
369
+
}
370
+
}
371
+
}
372
+
373
+
pub async fn has_passkeys_for_user(state: &AppState, did: &str) -> bool {
374
+
webauthn::has_passkeys(&state.db, did)
375
+
.await
376
+
.unwrap_or(false)
377
+
}
+749
src/api/server/totp.rs
+749
src/api/server/totp.rs
···
1
+
use crate::auth::BearerAuth;
2
+
use crate::auth::totp::{
3
+
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
4
+
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
5
+
verify_backup_code, verify_totp_code,
6
+
};
7
+
use crate::state::AppState;
8
+
use axum::{
9
+
Json,
10
+
extract::State,
11
+
http::StatusCode,
12
+
response::{IntoResponse, Response},
13
+
};
14
+
use chrono::Utc;
15
+
use serde::{Deserialize, Serialize};
16
+
use serde_json::json;
17
+
use tracing::{error, info, warn};
18
+
19
+
const ENCRYPTION_VERSION: i32 = 1;
20
+
21
+
#[derive(Serialize)]
22
+
#[serde(rename_all = "camelCase")]
23
+
pub struct CreateTotpSecretResponse {
24
+
pub secret: String,
25
+
pub uri: String,
26
+
pub qr_base64: String,
27
+
}
28
+
29
+
pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
30
+
let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
31
+
.fetch_optional(&state.db)
32
+
.await;
33
+
34
+
if let Ok(Some(true)) = existing {
35
+
return (
36
+
StatusCode::CONFLICT,
37
+
Json(json!({
38
+
"error": "TotpAlreadyEnabled",
39
+
"message": "TOTP is already enabled for this account"
40
+
})),
41
+
)
42
+
.into_response();
43
+
}
44
+
45
+
let secret = generate_totp_secret();
46
+
47
+
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did)
48
+
.fetch_optional(&state.db)
49
+
.await;
50
+
51
+
let handle = match handle {
52
+
Ok(Some(h)) => h,
53
+
Ok(None) => {
54
+
return (
55
+
StatusCode::NOT_FOUND,
56
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
57
+
)
58
+
.into_response();
59
+
}
60
+
Err(e) => {
61
+
error!("DB error fetching handle: {:?}", e);
62
+
return (
63
+
StatusCode::INTERNAL_SERVER_ERROR,
64
+
Json(json!({"error": "InternalError"})),
65
+
)
66
+
.into_response();
67
+
}
68
+
};
69
+
70
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
71
+
let uri = generate_totp_uri(&secret, &handle, &hostname);
72
+
73
+
let qr_code = match generate_qr_png_base64(&secret, &handle, &hostname) {
74
+
Ok(qr) => qr,
75
+
Err(e) => {
76
+
error!("Failed to generate QR code: {:?}", e);
77
+
return (
78
+
StatusCode::INTERNAL_SERVER_ERROR,
79
+
Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})),
80
+
)
81
+
.into_response();
82
+
}
83
+
};
84
+
85
+
let encrypted_secret = match encrypt_totp_secret(&secret) {
86
+
Ok(enc) => enc,
87
+
Err(e) => {
88
+
error!("Failed to encrypt TOTP secret: {:?}", e);
89
+
return (
90
+
StatusCode::INTERNAL_SERVER_ERROR,
91
+
Json(json!({"error": "InternalError"})),
92
+
)
93
+
.into_response();
94
+
}
95
+
};
96
+
97
+
let result = sqlx::query!(
98
+
r#"
99
+
INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)
100
+
VALUES ($1, $2, $3, false, NOW())
101
+
ON CONFLICT (did) DO UPDATE SET
102
+
secret_encrypted = $2,
103
+
encryption_version = $3,
104
+
verified = false,
105
+
created_at = NOW(),
106
+
last_used = NULL
107
+
"#,
108
+
auth.0.did,
109
+
encrypted_secret,
110
+
ENCRYPTION_VERSION
111
+
)
112
+
.execute(&state.db)
113
+
.await;
114
+
115
+
if let Err(e) = result {
116
+
error!("Failed to store TOTP secret: {:?}", e);
117
+
return (
118
+
StatusCode::INTERNAL_SERVER_ERROR,
119
+
Json(json!({"error": "InternalError"})),
120
+
)
121
+
.into_response();
122
+
}
123
+
124
+
let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
125
+
126
+
info!(did = %auth.0.did, "TOTP secret created (pending verification)");
127
+
128
+
Json(CreateTotpSecretResponse {
129
+
secret: secret_base32,
130
+
uri,
131
+
qr_base64: qr_code,
132
+
})
133
+
.into_response()
134
+
}
135
+
136
+
#[derive(Deserialize)]
137
+
pub struct EnableTotpInput {
138
+
pub code: String,
139
+
}
140
+
141
+
#[derive(Serialize)]
142
+
#[serde(rename_all = "camelCase")]
143
+
pub struct EnableTotpResponse {
144
+
pub backup_codes: Vec<String>,
145
+
}
146
+
147
+
pub async fn enable_totp(
148
+
State(state): State<AppState>,
149
+
auth: BearerAuth,
150
+
Json(input): Json<EnableTotpInput>,
151
+
) -> Response {
152
+
let totp_row = sqlx::query!(
153
+
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
154
+
auth.0.did
155
+
)
156
+
.fetch_optional(&state.db)
157
+
.await;
158
+
159
+
let totp_row = match totp_row {
160
+
Ok(Some(row)) => row,
161
+
Ok(None) => {
162
+
return (
163
+
StatusCode::BAD_REQUEST,
164
+
Json(json!({
165
+
"error": "TotpNotSetup",
166
+
"message": "Please call createTotpSecret first"
167
+
})),
168
+
)
169
+
.into_response();
170
+
}
171
+
Err(e) => {
172
+
error!("DB error fetching TOTP: {:?}", e);
173
+
return (
174
+
StatusCode::INTERNAL_SERVER_ERROR,
175
+
Json(json!({"error": "InternalError"})),
176
+
)
177
+
.into_response();
178
+
}
179
+
};
180
+
181
+
if totp_row.verified {
182
+
return (
183
+
StatusCode::CONFLICT,
184
+
Json(json!({
185
+
"error": "TotpAlreadyEnabled",
186
+
"message": "TOTP is already enabled"
187
+
})),
188
+
)
189
+
.into_response();
190
+
}
191
+
192
+
let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
193
+
{
194
+
Ok(s) => s,
195
+
Err(e) => {
196
+
error!("Failed to decrypt TOTP secret: {:?}", e);
197
+
return (
198
+
StatusCode::INTERNAL_SERVER_ERROR,
199
+
Json(json!({"error": "InternalError"})),
200
+
)
201
+
.into_response();
202
+
}
203
+
};
204
+
205
+
let code = input.code.trim();
206
+
if !verify_totp_code(&secret, code) {
207
+
return (
208
+
StatusCode::UNAUTHORIZED,
209
+
Json(json!({
210
+
"error": "InvalidCode",
211
+
"message": "Invalid verification code"
212
+
})),
213
+
)
214
+
.into_response();
215
+
}
216
+
217
+
let backup_codes = generate_backup_codes();
218
+
let mut tx = match state.db.begin().await {
219
+
Ok(tx) => tx,
220
+
Err(e) => {
221
+
error!("Failed to begin transaction: {:?}", e);
222
+
return (
223
+
StatusCode::INTERNAL_SERVER_ERROR,
224
+
Json(json!({"error": "InternalError"})),
225
+
)
226
+
.into_response();
227
+
}
228
+
};
229
+
230
+
if let Err(e) = sqlx::query!(
231
+
"UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
232
+
auth.0.did
233
+
)
234
+
.execute(&mut *tx)
235
+
.await
236
+
{
237
+
error!("Failed to enable TOTP: {:?}", e);
238
+
return (
239
+
StatusCode::INTERNAL_SERVER_ERROR,
240
+
Json(json!({"error": "InternalError"})),
241
+
)
242
+
.into_response();
243
+
}
244
+
245
+
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
246
+
.execute(&mut *tx)
247
+
.await
248
+
{
249
+
error!("Failed to clear old backup codes: {:?}", e);
250
+
return (
251
+
StatusCode::INTERNAL_SERVER_ERROR,
252
+
Json(json!({"error": "InternalError"})),
253
+
)
254
+
.into_response();
255
+
}
256
+
257
+
for code in &backup_codes {
258
+
let hash = match hash_backup_code(code) {
259
+
Ok(h) => h,
260
+
Err(e) => {
261
+
error!("Failed to hash backup code: {:?}", e);
262
+
return (
263
+
StatusCode::INTERNAL_SERVER_ERROR,
264
+
Json(json!({"error": "InternalError"})),
265
+
)
266
+
.into_response();
267
+
}
268
+
};
269
+
270
+
if let Err(e) = sqlx::query!(
271
+
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
272
+
auth.0.did,
273
+
hash
274
+
)
275
+
.execute(&mut *tx)
276
+
.await
277
+
{
278
+
error!("Failed to store backup code: {:?}", e);
279
+
return (
280
+
StatusCode::INTERNAL_SERVER_ERROR,
281
+
Json(json!({"error": "InternalError"})),
282
+
)
283
+
.into_response();
284
+
}
285
+
}
286
+
287
+
if let Err(e) = tx.commit().await {
288
+
error!("Failed to commit transaction: {:?}", e);
289
+
return (
290
+
StatusCode::INTERNAL_SERVER_ERROR,
291
+
Json(json!({"error": "InternalError"})),
292
+
)
293
+
.into_response();
294
+
}
295
+
296
+
info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
297
+
298
+
Json(EnableTotpResponse { backup_codes }).into_response()
299
+
}
300
+
301
+
#[derive(Deserialize)]
302
+
pub struct DisableTotpInput {
303
+
pub password: String,
304
+
pub code: String,
305
+
}
306
+
307
+
pub async fn disable_totp(
308
+
State(state): State<AppState>,
309
+
auth: BearerAuth,
310
+
Json(input): Json<DisableTotpInput>,
311
+
) -> Response {
312
+
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
313
+
.fetch_optional(&state.db)
314
+
.await;
315
+
316
+
let password_hash = match user {
317
+
Ok(Some(row)) => row.password_hash,
318
+
Ok(None) => {
319
+
return (
320
+
StatusCode::NOT_FOUND,
321
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
322
+
)
323
+
.into_response();
324
+
}
325
+
Err(e) => {
326
+
error!("DB error fetching user: {:?}", e);
327
+
return (
328
+
StatusCode::INTERNAL_SERVER_ERROR,
329
+
Json(json!({"error": "InternalError"})),
330
+
)
331
+
.into_response();
332
+
}
333
+
};
334
+
335
+
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
336
+
if !password_valid {
337
+
return (
338
+
StatusCode::UNAUTHORIZED,
339
+
Json(json!({
340
+
"error": "InvalidPassword",
341
+
"message": "Password is incorrect"
342
+
})),
343
+
)
344
+
.into_response();
345
+
}
346
+
347
+
let totp_row = sqlx::query!(
348
+
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
349
+
auth.0.did
350
+
)
351
+
.fetch_optional(&state.db)
352
+
.await;
353
+
354
+
let totp_row = match totp_row {
355
+
Ok(Some(row)) if row.verified => row,
356
+
Ok(Some(_)) | Ok(None) => {
357
+
return (
358
+
StatusCode::BAD_REQUEST,
359
+
Json(json!({
360
+
"error": "TotpNotEnabled",
361
+
"message": "TOTP is not enabled for this account"
362
+
})),
363
+
)
364
+
.into_response();
365
+
}
366
+
Err(e) => {
367
+
error!("DB error fetching TOTP: {:?}", e);
368
+
return (
369
+
StatusCode::INTERNAL_SERVER_ERROR,
370
+
Json(json!({"error": "InternalError"})),
371
+
)
372
+
.into_response();
373
+
}
374
+
};
375
+
376
+
let code = input.code.trim();
377
+
let code_valid = if is_backup_code_format(code) {
378
+
verify_backup_code_for_user(&state, &auth.0.did, code).await
379
+
} else {
380
+
let secret =
381
+
match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) {
382
+
Ok(s) => s,
383
+
Err(e) => {
384
+
error!("Failed to decrypt TOTP secret: {:?}", e);
385
+
return (
386
+
StatusCode::INTERNAL_SERVER_ERROR,
387
+
Json(json!({"error": "InternalError"})),
388
+
)
389
+
.into_response();
390
+
}
391
+
};
392
+
verify_totp_code(&secret, code)
393
+
};
394
+
395
+
if !code_valid {
396
+
return (
397
+
StatusCode::UNAUTHORIZED,
398
+
Json(json!({
399
+
"error": "InvalidCode",
400
+
"message": "Invalid verification code"
401
+
})),
402
+
)
403
+
.into_response();
404
+
}
405
+
406
+
let mut tx = match state.db.begin().await {
407
+
Ok(tx) => tx,
408
+
Err(e) => {
409
+
error!("Failed to begin transaction: {:?}", e);
410
+
return (
411
+
StatusCode::INTERNAL_SERVER_ERROR,
412
+
Json(json!({"error": "InternalError"})),
413
+
)
414
+
.into_response();
415
+
}
416
+
};
417
+
418
+
if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did)
419
+
.execute(&mut *tx)
420
+
.await
421
+
{
422
+
error!("Failed to delete TOTP: {:?}", e);
423
+
return (
424
+
StatusCode::INTERNAL_SERVER_ERROR,
425
+
Json(json!({"error": "InternalError"})),
426
+
)
427
+
.into_response();
428
+
}
429
+
430
+
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
431
+
.execute(&mut *tx)
432
+
.await
433
+
{
434
+
error!("Failed to delete backup codes: {:?}", e);
435
+
return (
436
+
StatusCode::INTERNAL_SERVER_ERROR,
437
+
Json(json!({"error": "InternalError"})),
438
+
)
439
+
.into_response();
440
+
}
441
+
442
+
if let Err(e) = tx.commit().await {
443
+
error!("Failed to commit transaction: {:?}", e);
444
+
return (
445
+
StatusCode::INTERNAL_SERVER_ERROR,
446
+
Json(json!({"error": "InternalError"})),
447
+
)
448
+
.into_response();
449
+
}
450
+
451
+
info!(did = %auth.0.did, "TOTP disabled");
452
+
453
+
(StatusCode::OK, Json(json!({}))).into_response()
454
+
}
455
+
456
+
#[derive(Serialize)]
457
+
#[serde(rename_all = "camelCase")]
458
+
pub struct GetTotpStatusResponse {
459
+
pub enabled: bool,
460
+
pub has_backup_codes: bool,
461
+
pub backup_codes_remaining: i64,
462
+
}
463
+
464
+
pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
465
+
let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
466
+
.fetch_optional(&state.db)
467
+
.await;
468
+
469
+
let enabled = match totp_row {
470
+
Ok(Some(row)) => row.verified,
471
+
Ok(None) => false,
472
+
Err(e) => {
473
+
error!("DB error fetching TOTP status: {:?}", e);
474
+
return (
475
+
StatusCode::INTERNAL_SERVER_ERROR,
476
+
Json(json!({"error": "InternalError"})),
477
+
)
478
+
.into_response();
479
+
}
480
+
};
481
+
482
+
let backup_count_row = sqlx::query!(
483
+
"SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
484
+
auth.0.did
485
+
)
486
+
.fetch_one(&state.db)
487
+
.await;
488
+
489
+
let backup_count = backup_count_row.map(|r| r.count.unwrap_or(0)).unwrap_or(0);
490
+
491
+
Json(GetTotpStatusResponse {
492
+
enabled,
493
+
has_backup_codes: backup_count > 0,
494
+
backup_codes_remaining: backup_count,
495
+
})
496
+
.into_response()
497
+
}
498
+
499
+
#[derive(Deserialize)]
500
+
pub struct RegenerateBackupCodesInput {
501
+
pub password: String,
502
+
pub code: String,
503
+
}
504
+
505
+
#[derive(Serialize)]
506
+
#[serde(rename_all = "camelCase")]
507
+
pub struct RegenerateBackupCodesResponse {
508
+
pub backup_codes: Vec<String>,
509
+
}
510
+
511
+
pub async fn regenerate_backup_codes(
512
+
State(state): State<AppState>,
513
+
auth: BearerAuth,
514
+
Json(input): Json<RegenerateBackupCodesInput>,
515
+
) -> Response {
516
+
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
517
+
.fetch_optional(&state.db)
518
+
.await;
519
+
520
+
let password_hash = match user {
521
+
Ok(Some(row)) => row.password_hash,
522
+
Ok(None) => {
523
+
return (
524
+
StatusCode::NOT_FOUND,
525
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
526
+
)
527
+
.into_response();
528
+
}
529
+
Err(e) => {
530
+
error!("DB error fetching user: {:?}", e);
531
+
return (
532
+
StatusCode::INTERNAL_SERVER_ERROR,
533
+
Json(json!({"error": "InternalError"})),
534
+
)
535
+
.into_response();
536
+
}
537
+
};
538
+
539
+
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
540
+
if !password_valid {
541
+
return (
542
+
StatusCode::UNAUTHORIZED,
543
+
Json(json!({
544
+
"error": "InvalidPassword",
545
+
"message": "Password is incorrect"
546
+
})),
547
+
)
548
+
.into_response();
549
+
}
550
+
551
+
let totp_row = sqlx::query!(
552
+
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
553
+
auth.0.did
554
+
)
555
+
.fetch_optional(&state.db)
556
+
.await;
557
+
558
+
let totp_row = match totp_row {
559
+
Ok(Some(row)) if row.verified => row,
560
+
Ok(Some(_)) | Ok(None) => {
561
+
return (
562
+
StatusCode::BAD_REQUEST,
563
+
Json(json!({
564
+
"error": "TotpNotEnabled",
565
+
"message": "TOTP must be enabled to regenerate backup codes"
566
+
})),
567
+
)
568
+
.into_response();
569
+
}
570
+
Err(e) => {
571
+
error!("DB error fetching TOTP: {:?}", e);
572
+
return (
573
+
StatusCode::INTERNAL_SERVER_ERROR,
574
+
Json(json!({"error": "InternalError"})),
575
+
)
576
+
.into_response();
577
+
}
578
+
};
579
+
580
+
let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
581
+
{
582
+
Ok(s) => s,
583
+
Err(e) => {
584
+
error!("Failed to decrypt TOTP secret: {:?}", e);
585
+
return (
586
+
StatusCode::INTERNAL_SERVER_ERROR,
587
+
Json(json!({"error": "InternalError"})),
588
+
)
589
+
.into_response();
590
+
}
591
+
};
592
+
593
+
let code = input.code.trim();
594
+
if !verify_totp_code(&secret, code) {
595
+
return (
596
+
StatusCode::UNAUTHORIZED,
597
+
Json(json!({
598
+
"error": "InvalidCode",
599
+
"message": "Invalid verification code"
600
+
})),
601
+
)
602
+
.into_response();
603
+
}
604
+
605
+
let backup_codes = generate_backup_codes();
606
+
let mut tx = match state.db.begin().await {
607
+
Ok(tx) => tx,
608
+
Err(e) => {
609
+
error!("Failed to begin transaction: {:?}", e);
610
+
return (
611
+
StatusCode::INTERNAL_SERVER_ERROR,
612
+
Json(json!({"error": "InternalError"})),
613
+
)
614
+
.into_response();
615
+
}
616
+
};
617
+
618
+
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
619
+
.execute(&mut *tx)
620
+
.await
621
+
{
622
+
error!("Failed to clear old backup codes: {:?}", e);
623
+
return (
624
+
StatusCode::INTERNAL_SERVER_ERROR,
625
+
Json(json!({"error": "InternalError"})),
626
+
)
627
+
.into_response();
628
+
}
629
+
630
+
for code in &backup_codes {
631
+
let hash = match hash_backup_code(code) {
632
+
Ok(h) => h,
633
+
Err(e) => {
634
+
error!("Failed to hash backup code: {:?}", e);
635
+
return (
636
+
StatusCode::INTERNAL_SERVER_ERROR,
637
+
Json(json!({"error": "InternalError"})),
638
+
)
639
+
.into_response();
640
+
}
641
+
};
642
+
643
+
if let Err(e) = sqlx::query!(
644
+
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
645
+
auth.0.did,
646
+
hash
647
+
)
648
+
.execute(&mut *tx)
649
+
.await
650
+
{
651
+
error!("Failed to store backup code: {:?}", e);
652
+
return (
653
+
StatusCode::INTERNAL_SERVER_ERROR,
654
+
Json(json!({"error": "InternalError"})),
655
+
)
656
+
.into_response();
657
+
}
658
+
}
659
+
660
+
if let Err(e) = tx.commit().await {
661
+
error!("Failed to commit transaction: {:?}", e);
662
+
return (
663
+
StatusCode::INTERNAL_SERVER_ERROR,
664
+
Json(json!({"error": "InternalError"})),
665
+
)
666
+
.into_response();
667
+
}
668
+
669
+
info!(did = %auth.0.did, "Backup codes regenerated");
670
+
671
+
Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
672
+
}
673
+
674
+
async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> bool {
675
+
let code = code.trim().to_uppercase();
676
+
677
+
let backup_codes = sqlx::query!(
678
+
"SELECT id, code_hash FROM backup_codes WHERE did = $1 AND used_at IS NULL",
679
+
did
680
+
)
681
+
.fetch_all(&state.db)
682
+
.await;
683
+
684
+
let backup_codes = match backup_codes {
685
+
Ok(codes) => codes,
686
+
Err(e) => {
687
+
warn!("Failed to fetch backup codes: {:?}", e);
688
+
return false;
689
+
}
690
+
};
691
+
692
+
for row in backup_codes {
693
+
if verify_backup_code(&code, &row.code_hash) {
694
+
let _ = sqlx::query!(
695
+
"UPDATE backup_codes SET used_at = $1 WHERE id = $2",
696
+
Utc::now(),
697
+
row.id
698
+
)
699
+
.execute(&state.db)
700
+
.await;
701
+
return true;
702
+
}
703
+
}
704
+
705
+
false
706
+
}
707
+
708
+
pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool {
709
+
let code = code.trim();
710
+
711
+
if is_backup_code_format(code) {
712
+
return verify_backup_code_for_user(state, did, code).await;
713
+
}
714
+
715
+
let totp_row = sqlx::query!(
716
+
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
717
+
did
718
+
)
719
+
.fetch_optional(&state.db)
720
+
.await;
721
+
722
+
let totp_row = match totp_row {
723
+
Ok(Some(row)) if row.verified => row,
724
+
_ => return false,
725
+
};
726
+
727
+
let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
728
+
{
729
+
Ok(s) => s,
730
+
Err(_) => return false,
731
+
};
732
+
733
+
if verify_totp_code(&secret, code) {
734
+
let _ = sqlx::query!("UPDATE user_totp SET last_used = NOW() WHERE did = $1", did)
735
+
.execute(&state.db)
736
+
.await;
737
+
return true;
738
+
}
739
+
740
+
false
741
+
}
742
+
743
+
pub async fn has_totp_enabled(state: &AppState, did: &str) -> bool {
744
+
let result = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", did)
745
+
.fetch_optional(&state.db)
746
+
.await;
747
+
748
+
matches!(result, Ok(Some(true)))
749
+
}
+2
src/auth/mod.rs
+2
src/auth/mod.rs
+194
src/auth/totp.rs
+194
src/auth/totp.rs
···
1
+
use base32::Alphabet;
2
+
use rand::RngCore;
3
+
use subtle::ConstantTimeEq;
4
+
use totp_rs::{Algorithm, TOTP};
5
+
6
+
const TOTP_DIGITS: usize = 6;
7
+
const TOTP_STEP: u64 = 30;
8
+
const TOTP_SECRET_LENGTH: usize = 20;
9
+
10
+
pub fn generate_totp_secret() -> Vec<u8> {
11
+
let mut secret = vec![0u8; TOTP_SECRET_LENGTH];
12
+
rand::thread_rng().fill_bytes(&mut secret);
13
+
secret
14
+
}
15
+
16
+
pub fn encrypt_totp_secret(secret: &[u8]) -> Result<Vec<u8>, String> {
17
+
crate::config::encrypt_key(secret)
18
+
}
19
+
20
+
pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result<Vec<u8>, String> {
21
+
crate::config::decrypt_key(encrypted, Some(version))
22
+
}
23
+
24
+
fn create_totp(
25
+
secret: Vec<u8>,
26
+
issuer: Option<String>,
27
+
account_name: String,
28
+
) -> Result<TOTP, String> {
29
+
TOTP::new(
30
+
Algorithm::SHA1,
31
+
TOTP_DIGITS,
32
+
1,
33
+
TOTP_STEP,
34
+
secret,
35
+
issuer,
36
+
account_name,
37
+
)
38
+
.map_err(|e| format!("Failed to create TOTP: {}", e))
39
+
}
40
+
41
+
pub fn verify_totp_code(secret: &[u8], code: &str) -> bool {
42
+
let code = code.trim();
43
+
if code.len() != TOTP_DIGITS {
44
+
return false;
45
+
}
46
+
47
+
let Ok(totp) = create_totp(secret.to_vec(), None, String::new()) else {
48
+
return false;
49
+
};
50
+
51
+
let now = std::time::SystemTime::now()
52
+
.duration_since(std::time::UNIX_EPOCH)
53
+
.map(|d| d.as_secs())
54
+
.unwrap_or(0);
55
+
56
+
for offset in [-1i64, 0, 1] {
57
+
let time = (now as i64 + offset * TOTP_STEP as i64) as u64;
58
+
let expected = totp.generate(time);
59
+
let is_valid: bool = code.as_bytes().ct_eq(expected.as_bytes()).into();
60
+
if is_valid {
61
+
return true;
62
+
}
63
+
}
64
+
65
+
false
66
+
}
67
+
68
+
pub fn generate_totp_uri(secret: &[u8], account_name: &str, issuer: &str) -> String {
69
+
let secret_base32 = base32::encode(Alphabet::Rfc4648 { padding: false }, secret);
70
+
format!(
71
+
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits={}&period={}",
72
+
urlencoding::encode(issuer),
73
+
urlencoding::encode(account_name),
74
+
secret_base32,
75
+
urlencoding::encode(issuer),
76
+
TOTP_DIGITS,
77
+
TOTP_STEP
78
+
)
79
+
}
80
+
81
+
pub fn generate_qr_png_base64(
82
+
secret: &[u8],
83
+
account_name: &str,
84
+
issuer: &str,
85
+
) -> Result<String, String> {
86
+
use base64::{Engine, engine::general_purpose::STANDARD};
87
+
88
+
let totp = create_totp(
89
+
secret.to_vec(),
90
+
Some(issuer.to_string()),
91
+
account_name.to_string(),
92
+
)?;
93
+
94
+
let qr_png = totp
95
+
.get_qr_png()
96
+
.map_err(|e| format!("Failed to generate QR code: {}", e))?;
97
+
98
+
Ok(STANDARD.encode(qr_png))
99
+
}
100
+
101
+
const BACKUP_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
102
+
const BACKUP_CODE_LENGTH: usize = 8;
103
+
const BACKUP_CODE_COUNT: usize = 10;
104
+
const BACKUP_CODE_BCRYPT_COST: u32 = 10;
105
+
106
+
pub fn generate_backup_codes() -> Vec<String> {
107
+
let mut codes = Vec::with_capacity(BACKUP_CODE_COUNT);
108
+
let mut rng = rand::thread_rng();
109
+
110
+
for _ in 0..BACKUP_CODE_COUNT {
111
+
let mut code = String::with_capacity(BACKUP_CODE_LENGTH);
112
+
for _ in 0..BACKUP_CODE_LENGTH {
113
+
let idx = (rng.next_u32() as usize) % BACKUP_CODE_ALPHABET.len();
114
+
code.push(BACKUP_CODE_ALPHABET[idx] as char);
115
+
}
116
+
codes.push(code);
117
+
}
118
+
119
+
codes
120
+
}
121
+
122
+
pub fn hash_backup_code(code: &str) -> Result<String, String> {
123
+
bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| format!("Failed to hash code: {}", e))
124
+
}
125
+
126
+
pub fn verify_backup_code(code: &str, hash: &str) -> bool {
127
+
bcrypt::verify(code, hash).unwrap_or(false)
128
+
}
129
+
130
+
pub fn is_backup_code_format(code: &str) -> bool {
131
+
let code = code.trim().to_uppercase();
132
+
code.len() == BACKUP_CODE_LENGTH
133
+
&& code
134
+
.chars()
135
+
.all(|c| BACKUP_CODE_ALPHABET.contains(&(c as u8)))
136
+
}
137
+
138
+
#[cfg(test)]
139
+
mod tests {
140
+
use super::*;
141
+
142
+
#[test]
143
+
fn test_generate_totp_secret() {
144
+
let secret = generate_totp_secret();
145
+
assert_eq!(secret.len(), TOTP_SECRET_LENGTH);
146
+
}
147
+
148
+
#[test]
149
+
fn test_verify_totp_code() {
150
+
let secret = generate_totp_secret();
151
+
let totp = create_totp(secret.clone(), None, String::new()).unwrap();
152
+
let code = totp.generate_current().unwrap();
153
+
assert!(verify_totp_code(&secret, &code));
154
+
assert!(!verify_totp_code(&secret, "000000"));
155
+
}
156
+
157
+
#[test]
158
+
fn test_generate_totp_uri() {
159
+
let secret = vec![0u8; 20];
160
+
let uri = generate_totp_uri(&secret, "test@example.com", "TestPDS");
161
+
assert!(uri.starts_with("otpauth://totp/"));
162
+
assert!(uri.contains("secret="));
163
+
assert!(uri.contains("issuer=TestPDS"));
164
+
}
165
+
166
+
#[test]
167
+
fn test_backup_codes() {
168
+
let codes = generate_backup_codes();
169
+
assert_eq!(codes.len(), BACKUP_CODE_COUNT);
170
+
for code in &codes {
171
+
assert_eq!(code.len(), BACKUP_CODE_LENGTH);
172
+
assert!(is_backup_code_format(code));
173
+
}
174
+
}
175
+
176
+
#[test]
177
+
fn test_backup_code_hash_verify() {
178
+
let codes = generate_backup_codes();
179
+
let code = &codes[0];
180
+
let hash = hash_backup_code(code).unwrap();
181
+
assert!(verify_backup_code(code, &hash));
182
+
assert!(!verify_backup_code("WRONGCOD", &hash));
183
+
}
184
+
185
+
#[test]
186
+
fn test_is_backup_code_format() {
187
+
assert!(is_backup_code_format("ABCD2345"));
188
+
assert!(is_backup_code_format(" abcd2345 "));
189
+
assert!(!is_backup_code_format("ABCD234"));
190
+
assert!(!is_backup_code_format("ABCD23456"));
191
+
assert!(!is_backup_code_format("ABCD234O"));
192
+
assert!(!is_backup_code_format("ABCD2341"));
193
+
}
194
+
}
+386
src/auth/webauthn.rs
+386
src/auth/webauthn.rs
···
1
+
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2
+
use chrono::{Duration, Utc};
3
+
use sqlx::{PgPool, Row};
4
+
use uuid::Uuid;
5
+
use webauthn_rs::prelude::*;
6
+
7
+
pub struct WebAuthnConfig {
8
+
webauthn: Webauthn,
9
+
}
10
+
11
+
impl WebAuthnConfig {
12
+
pub fn new(hostname: &str) -> Result<Self, String> {
13
+
let rp_id = hostname.to_string();
14
+
let rp_origin = Url::parse(&format!("https://{}", hostname))
15
+
.map_err(|e| format!("Invalid origin URL: {}", e))?;
16
+
17
+
let builder = WebauthnBuilder::new(&rp_id, &rp_origin)
18
+
.map_err(|e| format!("Failed to create WebAuthn builder: {}", e))?
19
+
.rp_name("Tranquil PDS")
20
+
.danger_set_user_presence_only_security_keys(true);
21
+
22
+
let webauthn = builder
23
+
.build()
24
+
.map_err(|e| format!("Failed to build WebAuthn: {}", e))?;
25
+
26
+
Ok(Self { webauthn })
27
+
}
28
+
29
+
pub fn start_registration(
30
+
&self,
31
+
user_id: &str,
32
+
username: &str,
33
+
display_name: &str,
34
+
exclude_credentials: Vec<CredentialID>,
35
+
) -> Result<(CreationChallengeResponse, SecurityKeyRegistration), String> {
36
+
let user_unique_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, user_id.as_bytes());
37
+
38
+
self.webauthn
39
+
.start_securitykey_registration(
40
+
user_unique_id,
41
+
username,
42
+
display_name,
43
+
if exclude_credentials.is_empty() {
44
+
None
45
+
} else {
46
+
Some(exclude_credentials)
47
+
},
48
+
None,
49
+
None,
50
+
)
51
+
.map_err(|e| format!("Failed to start registration: {}", e))
52
+
}
53
+
54
+
pub fn finish_registration(
55
+
&self,
56
+
reg: &RegisterPublicKeyCredential,
57
+
state: &SecurityKeyRegistration,
58
+
) -> Result<SecurityKey, String> {
59
+
self.webauthn
60
+
.finish_securitykey_registration(reg, state)
61
+
.map_err(|e| format!("Failed to finish registration: {}", e))
62
+
}
63
+
64
+
pub fn start_authentication(
65
+
&self,
66
+
credentials: Vec<SecurityKey>,
67
+
) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), String> {
68
+
self.webauthn
69
+
.start_securitykey_authentication(&credentials)
70
+
.map_err(|e| format!("Failed to start authentication: {}", e))
71
+
}
72
+
73
+
pub fn finish_authentication(
74
+
&self,
75
+
auth: &PublicKeyCredential,
76
+
state: &SecurityKeyAuthentication,
77
+
) -> Result<AuthenticationResult, String> {
78
+
self.webauthn
79
+
.finish_securitykey_authentication(auth, state)
80
+
.map_err(|e| format!("Failed to finish authentication: {}", e))
81
+
}
82
+
}
83
+
84
+
pub async fn save_registration_state(
85
+
pool: &PgPool,
86
+
did: &str,
87
+
state: &SecurityKeyRegistration,
88
+
) -> Result<Uuid, sqlx::Error> {
89
+
let id = Uuid::new_v4();
90
+
let state_json = serde_json::to_string(state)
91
+
.map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize state: {}", e)))?;
92
+
let challenge = id.as_bytes().to_vec();
93
+
let expires_at = Utc::now() + Duration::minutes(5);
94
+
95
+
sqlx::query!(
96
+
r#"
97
+
INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)
98
+
VALUES ($1, $2, $3, 'registration', $4, $5)
99
+
"#,
100
+
id,
101
+
did,
102
+
challenge,
103
+
state_json,
104
+
expires_at,
105
+
)
106
+
.execute(pool)
107
+
.await?;
108
+
109
+
Ok(id)
110
+
}
111
+
112
+
pub async fn load_registration_state(
113
+
pool: &PgPool,
114
+
did: &str,
115
+
) -> Result<Option<SecurityKeyRegistration>, sqlx::Error> {
116
+
let row = sqlx::query!(
117
+
r#"
118
+
SELECT state_json FROM webauthn_challenges
119
+
WHERE did = $1 AND challenge_type = 'registration' AND expires_at > NOW()
120
+
ORDER BY created_at DESC
121
+
LIMIT 1
122
+
"#,
123
+
did,
124
+
)
125
+
.fetch_optional(pool)
126
+
.await?;
127
+
128
+
match row {
129
+
Some(r) => {
130
+
let state: SecurityKeyRegistration =
131
+
serde_json::from_str(&r.state_json).map_err(|e| {
132
+
sqlx::Error::Protocol(format!("Failed to deserialize state: {}", e))
133
+
})?;
134
+
Ok(Some(state))
135
+
}
136
+
None => Ok(None),
137
+
}
138
+
}
139
+
140
+
pub async fn delete_registration_state(pool: &PgPool, did: &str) -> Result<(), sqlx::Error> {
141
+
sqlx::query!(
142
+
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'registration'",
143
+
did,
144
+
)
145
+
.execute(pool)
146
+
.await?;
147
+
Ok(())
148
+
}
149
+
150
+
pub async fn save_authentication_state(
151
+
pool: &PgPool,
152
+
did: &str,
153
+
state: &SecurityKeyAuthentication,
154
+
) -> Result<Uuid, sqlx::Error> {
155
+
let id = Uuid::new_v4();
156
+
let state_json = serde_json::to_string(state)
157
+
.map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize state: {}", e)))?;
158
+
let challenge = id.as_bytes().to_vec();
159
+
let expires_at = Utc::now() + Duration::minutes(5);
160
+
161
+
sqlx::query!(
162
+
r#"
163
+
INSERT INTO webauthn_challenges (id, did, challenge, challenge_type, state_json, expires_at)
164
+
VALUES ($1, $2, $3, 'authentication', $4, $5)
165
+
"#,
166
+
id,
167
+
did,
168
+
challenge,
169
+
state_json,
170
+
expires_at,
171
+
)
172
+
.execute(pool)
173
+
.await?;
174
+
175
+
Ok(id)
176
+
}
177
+
178
+
pub async fn load_authentication_state(
179
+
pool: &PgPool,
180
+
did: &str,
181
+
) -> Result<Option<SecurityKeyAuthentication>, sqlx::Error> {
182
+
let row = sqlx::query!(
183
+
r#"
184
+
SELECT state_json FROM webauthn_challenges
185
+
WHERE did = $1 AND challenge_type = 'authentication' AND expires_at > NOW()
186
+
ORDER BY created_at DESC
187
+
LIMIT 1
188
+
"#,
189
+
did,
190
+
)
191
+
.fetch_optional(pool)
192
+
.await?;
193
+
194
+
match row {
195
+
Some(r) => {
196
+
let state: SecurityKeyAuthentication =
197
+
serde_json::from_str(&r.state_json).map_err(|e| {
198
+
sqlx::Error::Protocol(format!("Failed to deserialize state: {}", e))
199
+
})?;
200
+
Ok(Some(state))
201
+
}
202
+
None => Ok(None),
203
+
}
204
+
}
205
+
206
+
pub async fn delete_authentication_state(pool: &PgPool, did: &str) -> Result<(), sqlx::Error> {
207
+
sqlx::query!(
208
+
"DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = 'authentication'",
209
+
did,
210
+
)
211
+
.execute(pool)
212
+
.await?;
213
+
Ok(())
214
+
}
215
+
216
+
pub async fn cleanup_expired_challenges(pool: &PgPool) -> Result<u64, sqlx::Error> {
217
+
let result = sqlx::query!("DELETE FROM webauthn_challenges WHERE expires_at < NOW()")
218
+
.execute(pool)
219
+
.await?;
220
+
Ok(result.rows_affected())
221
+
}
222
+
223
+
#[derive(Debug, Clone)]
224
+
pub struct StoredPasskey {
225
+
pub id: Uuid,
226
+
pub did: String,
227
+
pub credential_id: Vec<u8>,
228
+
pub public_key: Vec<u8>,
229
+
pub sign_count: i32,
230
+
pub created_at: chrono::DateTime<Utc>,
231
+
pub last_used: Option<chrono::DateTime<Utc>>,
232
+
pub friendly_name: Option<String>,
233
+
pub aaguid: Option<Vec<u8>>,
234
+
pub transports: Option<Vec<String>>,
235
+
}
236
+
237
+
impl StoredPasskey {
238
+
pub fn to_security_key(&self) -> Result<SecurityKey, String> {
239
+
serde_json::from_slice(&self.public_key)
240
+
.map_err(|e| format!("Failed to deserialize security key: {}", e))
241
+
}
242
+
243
+
pub fn credential_id_base64(&self) -> String {
244
+
URL_SAFE_NO_PAD.encode(&self.credential_id)
245
+
}
246
+
}
247
+
248
+
pub async fn save_passkey(
249
+
pool: &PgPool,
250
+
did: &str,
251
+
security_key: &SecurityKey,
252
+
friendly_name: Option<&str>,
253
+
) -> Result<Uuid, sqlx::Error> {
254
+
let id = Uuid::new_v4();
255
+
let credential_id = security_key.cred_id().to_vec();
256
+
let public_key = serde_json::to_vec(security_key)
257
+
.map_err(|e| sqlx::Error::Protocol(format!("Failed to serialize security key: {}", e)))?;
258
+
let aaguid: Option<Vec<u8>> = None;
259
+
260
+
sqlx::query!(
261
+
r#"
262
+
INSERT INTO passkeys (id, did, credential_id, public_key, sign_count, friendly_name, aaguid)
263
+
VALUES ($1, $2, $3, $4, 0, $5, $6)
264
+
"#,
265
+
id,
266
+
did,
267
+
credential_id,
268
+
public_key,
269
+
friendly_name,
270
+
aaguid,
271
+
)
272
+
.execute(pool)
273
+
.await?;
274
+
275
+
Ok(id)
276
+
}
277
+
278
+
pub async fn get_passkeys_for_user(
279
+
pool: &PgPool,
280
+
did: &str,
281
+
) -> Result<Vec<StoredPasskey>, sqlx::Error> {
282
+
let rows = sqlx::query!(
283
+
r#"
284
+
SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports
285
+
FROM passkeys
286
+
WHERE did = $1
287
+
ORDER BY created_at DESC
288
+
"#,
289
+
did,
290
+
)
291
+
.fetch_all(pool)
292
+
.await?;
293
+
294
+
Ok(rows
295
+
.into_iter()
296
+
.map(|r| StoredPasskey {
297
+
id: r.id,
298
+
did: r.did,
299
+
credential_id: r.credential_id,
300
+
public_key: r.public_key,
301
+
sign_count: r.sign_count,
302
+
created_at: r.created_at,
303
+
last_used: r.last_used,
304
+
friendly_name: r.friendly_name,
305
+
aaguid: r.aaguid,
306
+
transports: r.transports,
307
+
})
308
+
.collect())
309
+
}
310
+
311
+
pub async fn get_passkey_by_credential_id(
312
+
pool: &PgPool,
313
+
credential_id: &[u8],
314
+
) -> Result<Option<StoredPasskey>, sqlx::Error> {
315
+
let row = sqlx::query!(
316
+
r#"
317
+
SELECT id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name, aaguid, transports
318
+
FROM passkeys
319
+
WHERE credential_id = $1
320
+
"#,
321
+
credential_id,
322
+
)
323
+
.fetch_optional(pool)
324
+
.await?;
325
+
326
+
Ok(row.map(|r| StoredPasskey {
327
+
id: r.id,
328
+
did: r.did,
329
+
credential_id: r.credential_id,
330
+
public_key: r.public_key,
331
+
sign_count: r.sign_count,
332
+
created_at: r.created_at,
333
+
last_used: r.last_used,
334
+
friendly_name: r.friendly_name,
335
+
aaguid: r.aaguid,
336
+
transports: r.transports,
337
+
}))
338
+
}
339
+
340
+
pub async fn update_passkey_counter(
341
+
pool: &PgPool,
342
+
credential_id: &[u8],
343
+
new_counter: u32,
344
+
) -> Result<(), sqlx::Error> {
345
+
sqlx::query!(
346
+
"UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2",
347
+
new_counter as i32,
348
+
credential_id,
349
+
)
350
+
.execute(pool)
351
+
.await?;
352
+
Ok(())
353
+
}
354
+
355
+
pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result<bool, sqlx::Error> {
356
+
let result = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND did = $2")
357
+
.bind(id)
358
+
.bind(did)
359
+
.execute(pool)
360
+
.await?;
361
+
Ok(result.rows_affected() > 0)
362
+
}
363
+
364
+
pub async fn update_passkey_name(
365
+
pool: &PgPool,
366
+
id: Uuid,
367
+
did: &str,
368
+
name: &str,
369
+
) -> Result<bool, sqlx::Error> {
370
+
let result = sqlx::query("UPDATE passkeys SET friendly_name = $1 WHERE id = $2 AND did = $3")
371
+
.bind(name)
372
+
.bind(id)
373
+
.bind(did)
374
+
.execute(pool)
375
+
.await?;
376
+
Ok(result.rows_affected() > 0)
377
+
}
378
+
379
+
pub async fn has_passkeys(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
380
+
let row = sqlx::query("SELECT COUNT(*) as count FROM passkeys WHERE did = $1")
381
+
.bind(did)
382
+
.fetch_one(pool)
383
+
.await?;
384
+
let count: i64 = row.get("count");
385
+
Ok(count > 0)
386
+
}
+56
src/lib.rs
+56
src/lib.rs
···
279
279
get(api::server::get_account_invite_codes),
280
280
)
281
281
.route(
282
+
"/xrpc/com.atproto.server.createTotpSecret",
283
+
post(api::server::create_totp_secret),
284
+
)
285
+
.route(
286
+
"/xrpc/com.atproto.server.enableTotp",
287
+
post(api::server::enable_totp),
288
+
)
289
+
.route(
290
+
"/xrpc/com.atproto.server.disableTotp",
291
+
post(api::server::disable_totp),
292
+
)
293
+
.route(
294
+
"/xrpc/com.atproto.server.getTotpStatus",
295
+
get(api::server::get_totp_status),
296
+
)
297
+
.route(
298
+
"/xrpc/com.atproto.server.regenerateBackupCodes",
299
+
post(api::server::regenerate_backup_codes),
300
+
)
301
+
.route(
302
+
"/xrpc/com.atproto.server.startPasskeyRegistration",
303
+
post(api::server::start_passkey_registration),
304
+
)
305
+
.route(
306
+
"/xrpc/com.atproto.server.finishPasskeyRegistration",
307
+
post(api::server::finish_passkey_registration),
308
+
)
309
+
.route(
310
+
"/xrpc/com.atproto.server.listPasskeys",
311
+
get(api::server::list_passkeys),
312
+
)
313
+
.route(
314
+
"/xrpc/com.atproto.server.deletePasskey",
315
+
post(api::server::delete_passkey),
316
+
)
317
+
.route(
318
+
"/xrpc/com.atproto.server.updatePasskey",
319
+
post(api::server::update_passkey),
320
+
)
321
+
.route(
282
322
"/xrpc/com.atproto.admin.getInviteCodes",
283
323
get(api::admin::get_invite_codes),
284
324
)
···
358
398
.route(
359
399
"/oauth/authorize/2fa",
360
400
post(oauth::endpoints::authorize_2fa_post),
401
+
)
402
+
.route(
403
+
"/oauth/passkey/check",
404
+
get(oauth::endpoints::check_user_has_passkeys),
405
+
)
406
+
.route(
407
+
"/oauth/security-status",
408
+
get(oauth::endpoints::check_user_security_status),
409
+
)
410
+
.route(
411
+
"/oauth/passkey/start",
412
+
post(oauth::endpoints::passkey_start),
413
+
)
414
+
.route(
415
+
"/oauth/passkey/finish",
416
+
post(oauth::endpoints::passkey_finish),
361
417
)
362
418
.route(
363
419
"/oauth/authorize/deny",