I think it would be nice to have the legacy 2fa available for fun.
+112
.sqlx/query-297fcbb356d65aae3faae5430000b6c6fbec8566a4adbb595c91606fdfa3bedc.json
+112
.sqlx/query-297fcbb356d65aae3faae5430000b6c6fbec8566a4adbb595c91606fdfa3bedc.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT handle, email, email_verified, is_admin, deactivated_at, takedown_ref,\n preferred_locale,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n discord_verified, telegram_verified, signal_verified,\n migrated_to_pds, migrated_at,\n (SELECT verified FROM user_totp WHERE did = users.did) as totp_enabled\n FROM users\n WHERE did = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "handle",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "email",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "email_verified",
19
+
"type_info": "Bool"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "is_admin",
24
+
"type_info": "Bool"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "deactivated_at",
29
+
"type_info": "Timestamptz"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "takedown_ref",
34
+
"type_info": "Text"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
+
"name": "preferred_locale",
39
+
"type_info": "Varchar"
40
+
},
41
+
{
42
+
"ordinal": 7,
43
+
"name": "preferred_comms_channel!: CommsChannel",
44
+
"type_info": {
45
+
"Custom": {
46
+
"name": "comms_channel",
47
+
"kind": {
48
+
"Enum": [
49
+
"email",
50
+
"discord",
51
+
"telegram",
52
+
"signal"
53
+
]
54
+
}
55
+
}
56
+
}
57
+
},
58
+
{
59
+
"ordinal": 8,
60
+
"name": "discord_verified",
61
+
"type_info": "Bool"
62
+
},
63
+
{
64
+
"ordinal": 9,
65
+
"name": "telegram_verified",
66
+
"type_info": "Bool"
67
+
},
68
+
{
69
+
"ordinal": 10,
70
+
"name": "signal_verified",
71
+
"type_info": "Bool"
72
+
},
73
+
{
74
+
"ordinal": 11,
75
+
"name": "migrated_to_pds",
76
+
"type_info": "Text"
77
+
},
78
+
{
79
+
"ordinal": 12,
80
+
"name": "migrated_at",
81
+
"type_info": "Timestamptz"
82
+
},
83
+
{
84
+
"ordinal": 13,
85
+
"name": "totp_enabled",
86
+
"type_info": "Bool"
87
+
}
88
+
],
89
+
"parameters": {
90
+
"Left": [
91
+
"Text"
92
+
]
93
+
},
94
+
"nullable": [
95
+
false,
96
+
true,
97
+
false,
98
+
false,
99
+
true,
100
+
true,
101
+
true,
102
+
false,
103
+
false,
104
+
false,
105
+
false,
106
+
true,
107
+
true,
108
+
null
109
+
]
110
+
},
111
+
"hash": "297fcbb356d65aae3faae5430000b6c6fbec8566a4adbb595c91606fdfa3bedc"
112
+
}
+15
.sqlx/query-3b056b9e79847c8bbb8507f283213e7209b417e7933f5b2277a83cae7e1c7888.json
+15
.sqlx/query-3b056b9e79847c8bbb8507f283213e7209b417e7933f5b2277a83cae7e1c7888.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM account_preferences WHERE user_id = $1 AND name = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Text"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "3b056b9e79847c8bbb8507f283213e7209b417e7933f5b2277a83cae7e1c7888"
15
+
}
+136
.sqlx/query-a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249.json
+136
.sqlx/query-a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
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": "handle",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "password_hash",
24
+
"type_info": "Text"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "email",
29
+
"type_info": "Text"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "deactivated_at",
34
+
"type_info": "Timestamptz"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
+
"name": "takedown_ref",
39
+
"type_info": "Text"
40
+
},
41
+
{
42
+
"ordinal": 7,
43
+
"name": "email_verified",
44
+
"type_info": "Bool"
45
+
},
46
+
{
47
+
"ordinal": 8,
48
+
"name": "discord_verified",
49
+
"type_info": "Bool"
50
+
},
51
+
{
52
+
"ordinal": 9,
53
+
"name": "telegram_verified",
54
+
"type_info": "Bool"
55
+
},
56
+
{
57
+
"ordinal": 10,
58
+
"name": "signal_verified",
59
+
"type_info": "Bool"
60
+
},
61
+
{
62
+
"ordinal": 11,
63
+
"name": "allow_legacy_login",
64
+
"type_info": "Bool"
65
+
},
66
+
{
67
+
"ordinal": 12,
68
+
"name": "migrated_to_pds",
69
+
"type_info": "Text"
70
+
},
71
+
{
72
+
"ordinal": 13,
73
+
"name": "preferred_comms_channel: CommsChannel",
74
+
"type_info": {
75
+
"Custom": {
76
+
"name": "comms_channel",
77
+
"kind": {
78
+
"Enum": [
79
+
"email",
80
+
"discord",
81
+
"telegram",
82
+
"signal"
83
+
]
84
+
}
85
+
}
86
+
}
87
+
},
88
+
{
89
+
"ordinal": 14,
90
+
"name": "key_bytes",
91
+
"type_info": "Bytea"
92
+
},
93
+
{
94
+
"ordinal": 15,
95
+
"name": "encryption_version",
96
+
"type_info": "Int4"
97
+
},
98
+
{
99
+
"ordinal": 16,
100
+
"name": "totp_enabled",
101
+
"type_info": "Bool"
102
+
},
103
+
{
104
+
"ordinal": 17,
105
+
"name": "email_2fa_enabled!",
106
+
"type_info": "Bool"
107
+
}
108
+
],
109
+
"parameters": {
110
+
"Left": [
111
+
"Text"
112
+
]
113
+
},
114
+
"nullable": [
115
+
false,
116
+
false,
117
+
false,
118
+
true,
119
+
true,
120
+
true,
121
+
true,
122
+
false,
123
+
false,
124
+
false,
125
+
false,
126
+
false,
127
+
true,
128
+
false,
129
+
false,
130
+
true,
131
+
null,
132
+
null
133
+
]
134
+
},
135
+
"hash": "a960b981a146a0e422ef53601dfc31e29cf777aa194227c48c6ebc6905ea3249"
136
+
}
+118
.sqlx/query-c8728a1247c535e941e2b3bcb4100d7b3610f31c7acfdc1f8c072e1c5ca0ea18.json
+118
.sqlx/query-c8728a1247c535e941e2b3bcb4100d7b3610f31c7acfdc1f8c072e1c5ca0ea18.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT u.handle, u.email, u.email_verified, u.is_admin, u.deactivated_at, u.takedown_ref,\n u.preferred_locale,\n u.preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n u.discord_verified, u.telegram_verified, u.signal_verified,\n u.migrated_to_pds, u.migrated_at,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,\n COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as \"email_2fa_enabled!\"\n FROM users u\n WHERE u.did = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "handle",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "email",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "email_verified",
19
+
"type_info": "Bool"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "is_admin",
24
+
"type_info": "Bool"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "deactivated_at",
29
+
"type_info": "Timestamptz"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "takedown_ref",
34
+
"type_info": "Text"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
+
"name": "preferred_locale",
39
+
"type_info": "Varchar"
40
+
},
41
+
{
42
+
"ordinal": 7,
43
+
"name": "preferred_comms_channel!: CommsChannel",
44
+
"type_info": {
45
+
"Custom": {
46
+
"name": "comms_channel",
47
+
"kind": {
48
+
"Enum": [
49
+
"email",
50
+
"discord",
51
+
"telegram",
52
+
"signal"
53
+
]
54
+
}
55
+
}
56
+
}
57
+
},
58
+
{
59
+
"ordinal": 8,
60
+
"name": "discord_verified",
61
+
"type_info": "Bool"
62
+
},
63
+
{
64
+
"ordinal": 9,
65
+
"name": "telegram_verified",
66
+
"type_info": "Bool"
67
+
},
68
+
{
69
+
"ordinal": 10,
70
+
"name": "signal_verified",
71
+
"type_info": "Bool"
72
+
},
73
+
{
74
+
"ordinal": 11,
75
+
"name": "migrated_to_pds",
76
+
"type_info": "Text"
77
+
},
78
+
{
79
+
"ordinal": 12,
80
+
"name": "migrated_at",
81
+
"type_info": "Timestamptz"
82
+
},
83
+
{
84
+
"ordinal": 13,
85
+
"name": "totp_enabled",
86
+
"type_info": "Bool"
87
+
},
88
+
{
89
+
"ordinal": 14,
90
+
"name": "email_2fa_enabled!",
91
+
"type_info": "Bool"
92
+
}
93
+
],
94
+
"parameters": {
95
+
"Left": [
96
+
"Text"
97
+
]
98
+
},
99
+
"nullable": [
100
+
false,
101
+
true,
102
+
false,
103
+
false,
104
+
true,
105
+
true,
106
+
true,
107
+
false,
108
+
false,
109
+
false,
110
+
false,
111
+
true,
112
+
true,
113
+
null,
114
+
null
115
+
]
116
+
},
117
+
"hash": "c8728a1247c535e941e2b3bcb4100d7b3610f31c7acfdc1f8c072e1c5ca0ea18"
118
+
}
+4
crates/tranquil-cache/src/lib.rs
+4
crates/tranquil-cache/src/lib.rs
+7
crates/tranquil-comms/src/locale.rs
+7
crates/tranquil-comms/src/locale.rs
···
16
16
pub password_reset_body: &'static str,
17
17
pub email_update_subject: &'static str,
18
18
pub email_update_body: &'static str,
19
+
pub short_token_body: &'static str,
19
20
pub account_deletion_subject: &'static str,
20
21
pub account_deletion_body: &'static str,
21
22
pub plc_operation_subject: &'static str,
···
50
51
password_reset_body: "Hello @{handle},\n\nYour password reset code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.",
51
52
email_update_subject: "Confirm your new email - {hostname}",
52
53
email_update_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 10 minutes.\n\nOr if you like to live dangerously:\n{verify_link}\n\nIf you did not request this, please ignore this email.",
54
+
short_token_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nThis code will expire in 15 minutes.\n\nIf you did not request this, please ignore this email.",
53
55
account_deletion_subject: "Account Deletion Request - {hostname}",
54
56
account_deletion_body: "Hello @{handle},\n\nYour account deletion confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
55
57
plc_operation_subject: "{hostname} - PLC Operation Token",
···
73
75
password_reset_body: "ๆจๅฅฝ @{handle}๏ผ\n\nๆจ็ๅฏ็ ้็ฝฎ้ช่ฏ็ ๆฏ๏ผ{code}\n\nๆญค้ช่ฏ็ ๅฐๅจ10ๅ้ๅ่ฟๆใ\n\nๅฆๆ่ฟไธๆฏๆจ็ๆไฝ๏ผ่ฏทๅฟฝ็ฅๆญคๆถๆฏใ",
74
76
email_update_subject: "็กฎ่ฎคๆจ็ๆฐ้ฎ็ฎฑ - {hostname}",
75
77
email_update_body: "ๆจๅฅฝ @{handle}๏ผ\n\nๆจ็้ช่ฏ็ ๆฏ๏ผ\n{code}\n\nๅคๅถไธ่ฟฐ้ช่ฏ็ ๅนถๅจๆญค่พๅ
ฅ๏ผ\n{verify_page}\n\nๆญค้ช่ฏ็ ๅฐๅจ10ๅ้ๅ่ฟๆใ\n\nๆ่
็ดๆฅ็นๅป้พๆฅ๏ผ\n{verify_link}\n\nๅฆๆ่ฟไธๆฏๆจ็ๆไฝ๏ผ่ฏทๅฟฝ็ฅๆญค้ฎไปถใ",
78
+
short_token_body: "ๆจๅฅฝ @{handle}๏ผ\n\nๆจ็้ช่ฏ็ ๆฏ๏ผ\n{code}\n\nๆญค้ช่ฏ็ ๅฐๅจ15ๅ้ๅ่ฟๆใ\n\nๅฆๆ่ฟไธๆฏๆจ็ๆไฝ๏ผ่ฏทๅฟฝ็ฅๆญค้ฎไปถใ",
76
79
account_deletion_subject: "่ดฆๆทๅ ้ค่ฏทๆฑ - {hostname}",
77
80
account_deletion_body: "ๆจๅฅฝ @{handle}๏ผ\n\nๆจ็่ดฆๆทๅ ้ค็กฎ่ฎค็ ๆฏ๏ผ{code}\n\nๆญค้ช่ฏ็ ๅฐๅจ10ๅ้ๅ่ฟๆใ\n\nๅฆๆ่ฟไธๆฏๆจ็ๆไฝ๏ผ่ฏท็ซๅณไฟๆคๆจ็่ดฆๆทใ",
78
81
plc_operation_subject: "{hostname} - PLC ๆไฝไปค็",
···
96
99
password_reset_body: "@{handle} ๆง\n\nใในใฏใผใใชใปใใใณใผใใฏ๏ผ{code}\n\nใใฎใณใผใใฏ10ๅๅพใซๆ้ๅใใจใชใใพใใ\n\nใใฎๆไฝใซๅฟๅฝใใใใชใๅ ดๅใฏใใใฎใกใใปใผใธใ็ก่ฆใใฆใใ ใใใ",
97
100
email_update_subject: "ๆฐใใใกใผใซใขใใฌในใฎ็ขบ่ช - {hostname}",
98
101
email_update_body: "@{handle} ๆง\n\n็ขบ่ชใณใผใใฏ๏ผ\n{code}\n\nไธ่จใฎใณใผใใใณใใผใใฆใใใกใใงๅ
ฅๅใใฆใใ ใใ๏ผ\n{verify_page}\n\nใใฎใณใผใใฏ10ๅๅพใซๆ้ๅใใจใชใใพใใ\n\n่ชๅทฑ่ฒฌไปปใงใฏใณใฏใชใใฏ่ช่จผ๏ผ\n{verify_link}\n\nใใฎๆไฝใซๅฟๅฝใใใใชใๅ ดๅใฏใใใฎใกใผใซใ็ก่ฆใใฆใใ ใใใ",
102
+
short_token_body: "@{handle} ๆง\n\n็ขบ่ชใณใผใใฏ๏ผ\n{code}\n\nใใฎใณใผใใฏ15ๅๅพใซๆ้ๅใใจใชใใพใใ\n\nใใฎๆไฝใซๅฟๅฝใใใใชใๅ ดๅใฏใใใฎใกใผใซใ็ก่ฆใใฆใใ ใใใ",
99
103
account_deletion_subject: "ใขใซใฆใณใๅ้คใชใฏใจในใ - {hostname}",
100
104
account_deletion_body: "@{handle} ๆง\n\nใขใซใฆใณใๅ้คใฎ็ขบ่ชใณใผใใฏ๏ผ{code}\n\nใใฎใณใผใใฏ10ๅๅพใซๆ้ๅใใจใชใใพใใ\n\nใใฎๆไฝใซๅฟๅฝใใใใชใๅ ดๅใฏใ็ดใกใซใขใซใฆใณใใไฟ่ญทใใฆใใ ใใใ",
101
105
plc_operation_subject: "{hostname} - PLC ๆไฝใใผใฏใณ",
···
119
123
password_reset_body: "์๋
ํ์ธ์ @{handle}๋,\n\n๋น๋ฐ๋ฒํธ ์ฌ์ค์ ์ฝ๋๋: {code}\n\n์ด ์ฝ๋๋ 10๋ถ ํ์ ๋ง๋ฃ๋ฉ๋๋ค.\n\n์์ฒญํ์ง ์์ผ์
จ๋ค๋ฉด ์ด ๋ฉ์์ง๋ฅผ ๋ฌด์ํ์ธ์.",
120
124
email_update_subject: "์ ์ด๋ฉ์ผ ์ฃผ์ ํ์ธ - {hostname}",
121
125
email_update_body: "์๋
ํ์ธ์ @{handle}๋,\n\n์ธ์ฆ ์ฝ๋๋:\n{code}\n\n์ ์ฝ๋๋ฅผ ๋ณต์ฌํ์ฌ ์ฌ๊ธฐ์ ์
๋ ฅํ์ธ์:\n{verify_page}\n\n์ด ์ฝ๋๋ 10๋ถ ํ์ ๋ง๋ฃ๋ฉ๋๋ค.\n\n์ํ์ ๊ฐ์ํ๊ณ ์ํด๋ฆญ ์ธ์ฆ:\n{verify_link}\n\n์์ฒญํ์ง ์์ผ์
จ๋ค๋ฉด ์ด ์ด๋ฉ์ผ์ ๋ฌด์ํ์ธ์.",
126
+
short_token_body: "์๋
ํ์ธ์ @{handle}๋,\n\n์ธ์ฆ ์ฝ๋๋:\n{code}\n\n์ด ์ฝ๋๋ 15๋ถ ํ์ ๋ง๋ฃ๋ฉ๋๋ค.\n\n์์ฒญํ์ง ์์ผ์
จ๋ค๋ฉด ์ด ์ด๋ฉ์ผ์ ๋ฌด์ํ์ธ์.",
122
127
account_deletion_subject: "๊ณ์ ์ญ์ ์์ฒญ - {hostname}",
123
128
account_deletion_body: "์๋
ํ์ธ์ @{handle}๋,\n\n๊ณ์ ์ญ์ ํ์ธ ์ฝ๋๋: {code}\n\n์ด ์ฝ๋๋ 10๋ถ ํ์ ๋ง๋ฃ๋ฉ๋๋ค.\n\n์์ฒญํ์ง ์์ผ์
จ๋ค๋ฉด ์ฆ์ ๊ณ์ ์ ๋ณดํธํ์ธ์.",
124
129
plc_operation_subject: "{hostname} - PLC ์์
ํ ํฐ",
···
142
147
password_reset_body: "Hej @{handle},\n\nDin kod fรถr lรถsenordsรฅterstรคllning รคr: {code}\n\nDenna kod upphรถr om 10 minuter.\n\nOm du inte begรคrde detta kan du ignorera detta meddelande.",
143
148
email_update_subject: "Bekrรคfta din nya e-post - {hostname}",
144
149
email_update_body: "Hej @{handle},\n\nDin verifieringskod รคr:\n{code}\n\nKopiera koden ovan och ange den pรฅ:\n{verify_page}\n\nDenna kod upphรถr om 10 minuter.\n\nEller om du gillar att leva farligt:\n{verify_link}\n\nOm du inte begรคrde detta kan du ignorera detta meddelande.",
150
+
short_token_body: "Hej @{handle},\n\nDin verifieringskod รคr:\n{code}\n\nDenna kod upphรถr om 15 minuter.\n\nOm du inte begรคrde detta kan du ignorera detta meddelande.",
145
151
account_deletion_subject: "Begรคran om kontoradering - {hostname}",
146
152
account_deletion_body: "Hej @{handle},\n\nDin bekrรคftelsekod fรถr kontoradering รคr: {code}\n\nDenna kod upphรถr om 10 minuter.\n\nOm du inte begรคrde detta, skydda ditt konto omedelbart.",
147
153
plc_operation_subject: "{hostname} - PLC-operationstoken",
···
165
171
password_reset_body: "Hei @{handle},\n\nSalasanan palautuskoodisi on: {code}\n\nTรคmรค koodi vanhenee 10 minuutissa.\n\nJos et pyytรคnyt tรคtรค, voit jรคttรครค tรคmรคn viestin huomiotta.",
166
172
email_update_subject: "Vahvista uusi sรคhkรถpostisi - {hostname}",
167
173
email_update_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllรค ja syรถtรค se osoitteessa:\n{verify_page}\n\nTรคmรค koodi vanhenee 10 minuutissa.\n\nTai jos pidรคt vaarallisesta elรคmรคstรค:\n{verify_link}\n\nJos et pyytรคnyt tรคtรค, voit jรคttรครค tรคmรคn viestin huomiotta.",
174
+
short_token_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nTรคmรค koodi vanhenee 15 minuutissa.\n\nJos et pyytรคnyt tรคtรค, voit jรคttรครค tรคmรคn viestin huomiotta.",
168
175
account_deletion_subject: "Tilin poistopyyntรถ - {hostname}",
169
176
account_deletion_body: "Hei @{handle},\n\nTilin poiston vahvistuskoodisi on: {code}\n\nTรคmรค koodi vanhenee 10 minuutissa.\n\nJos et pyytรคnyt tรคtรค, suojaa tilisi vรคlittรถmรคsti.",
170
177
plc_operation_subject: "{hostname} - PLC-toimintotunniste",
+3
crates/tranquil-db-traits/src/user.rs
+3
crates/tranquil-db-traits/src/user.rs
···
768
768
pub channel_verification: ChannelVerificationStatus,
769
769
pub migrated_to_pds: Option<String>,
770
770
pub migrated_at: Option<DateTime<Utc>>,
771
+
pub totp_enabled: bool,
772
+
pub email_2fa_enabled: bool,
771
773
}
772
774
773
775
#[derive(Debug, Clone)]
···
792
794
pub key_bytes: Vec<u8>,
793
795
pub encryption_version: Option<i32>,
794
796
pub totp_enabled: bool,
797
+
pub email_2fa_enabled: bool,
795
798
}
796
799
797
800
#[derive(Debug, Clone)]
+15
-3
crates/tranquil-db/src/postgres/infra.rs
+15
-3
crates/tranquil-db/src/postgres/infra.rs
···
661
661
name: &str,
662
662
value_json: serde_json::Value,
663
663
) -> Result<(), DbError> {
664
+
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
665
+
664
666
sqlx::query!(
665
-
r#"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)
666
-
ON CONFLICT (user_id, name) DO UPDATE SET value_json = $3"#,
667
+
r#"DELETE FROM account_preferences WHERE user_id = $1 AND name = $2"#,
668
+
user_id,
669
+
name
670
+
)
671
+
.execute(&mut *tx)
672
+
.await
673
+
.map_err(map_sqlx_error)?;
674
+
675
+
sqlx::query!(
676
+
r#"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)"#,
667
677
user_id,
668
678
name,
669
679
value_json
670
680
)
671
-
.execute(&self.pool)
681
+
.execute(&mut *tx)
672
682
.await
673
683
.map_err(map_sqlx_error)?;
674
684
685
+
tx.commit().await.map_err(map_sqlx_error)?;
686
+
675
687
Ok(())
676
688
}
677
689
+14
-8
crates/tranquil-db/src/postgres/user.rs
+14
-8
crates/tranquil-db/src/postgres/user.rs
···
1374
1374
async fn get_session_info_by_did(&self, did: &Did) -> Result<Option<UserSessionInfo>, DbError> {
1375
1375
sqlx::query!(
1376
1376
r#"
1377
-
SELECT handle, email, email_verified, is_admin, deactivated_at, takedown_ref,
1378
-
preferred_locale,
1379
-
preferred_comms_channel as "preferred_comms_channel!: CommsChannel",
1380
-
discord_verified, telegram_verified, signal_verified,
1381
-
migrated_to_pds, migrated_at
1382
-
FROM users
1383
-
WHERE did = $1
1377
+
SELECT u.handle, u.email, u.email_verified, u.is_admin, u.deactivated_at, u.takedown_ref,
1378
+
u.preferred_locale,
1379
+
u.preferred_comms_channel as "preferred_comms_channel!: CommsChannel",
1380
+
u.discord_verified, u.telegram_verified, u.signal_verified,
1381
+
u.migrated_to_pds, u.migrated_at,
1382
+
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,
1383
+
COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!"
1384
+
FROM users u
1385
+
WHERE u.did = $1
1384
1386
"#,
1385
1387
did.as_str()
1386
1388
)
···
1404
1406
),
1405
1407
migrated_to_pds: row.migrated_to_pds,
1406
1408
migrated_at: row.migrated_at,
1409
+
totp_enabled: row.totp_enabled.unwrap_or(false),
1410
+
email_2fa_enabled: row.email_2fa_enabled,
1407
1411
})
1408
1412
})
1409
1413
}
···
1468
1472
u.allow_legacy_login, u.migrated_to_pds,
1469
1473
u.preferred_comms_channel as "preferred_comms_channel: CommsChannel",
1470
1474
k.key_bytes, k.encryption_version,
1471
-
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled
1475
+
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled,
1476
+
COALESCE((SELECT (value_json)::boolean FROM account_preferences WHERE user_id = u.id AND name = 'email_auth_factor' ORDER BY created_at DESC LIMIT 1), false) as "email_2fa_enabled!"
1472
1477
FROM users u
1473
1478
JOIN user_keys k ON u.id = k.user_id
1474
1479
WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#,
···
1498
1503
key_bytes: row.key_bytes,
1499
1504
encryption_version: row.encryption_version,
1500
1505
totp_enabled: row.totp_enabled.unwrap_or(false),
1506
+
email_2fa_enabled: row.email_2fa_enabled,
1501
1507
})
1502
1508
})
1503
1509
}
+3
crates/tranquil-infra/src/lib.rs
+3
crates/tranquil-infra/src/lib.rs
···
73
73
async fn delete(&self, key: &str) -> Result<(), CacheError>;
74
74
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>>;
75
75
async fn set_bytes(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError>;
76
+
fn is_available(&self) -> bool {
77
+
true
78
+
}
76
79
}
77
80
78
81
#[async_trait]
+14
-2
crates/tranquil-pds/src/api/error.rs
+14
-2
crates/tranquil-pds/src/api/error.rs
···
114
114
SsoSessionExpired,
115
115
SsoAlreadyLinked,
116
116
SsoLinkNotFound,
117
+
AuthFactorTokenRequired,
118
+
LegacyLoginBlocked,
117
119
}
118
120
119
121
impl ApiError {
···
132
134
| Self::AuthenticationFailed(_)
133
135
| Self::AccountDeactivated
134
136
| Self::AccountTakedown
135
-
| Self::InvalidCode(_)
136
137
| Self::InvalidPassword(_)
137
138
| Self::InvalidToken(_)
138
139
| Self::PasskeyCounterAnomaly
139
140
| Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED,
141
+
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
140
142
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
141
143
Self::Forbidden
142
144
| Self::AdminRequired
···
210
212
| Self::SsoInvalidAction
211
213
| Self::SsoNotAuthenticated
212
214
| Self::SsoSessionExpired
213
-
| Self::SsoAlreadyLinked => StatusCode::BAD_REQUEST,
215
+
| Self::SsoAlreadyLinked
216
+
| Self::AuthFactorTokenRequired
217
+
| Self::LegacyLoginBlocked => StatusCode::BAD_REQUEST,
214
218
Self::PasskeyNotFound | Self::SsoLinkNotFound => StatusCode::NOT_FOUND,
215
219
}
216
220
}
···
313
317
Self::SsoSessionExpired => Cow::Borrowed("SsoSessionExpired"),
314
318
Self::SsoAlreadyLinked => Cow::Borrowed("SsoAlreadyLinked"),
315
319
Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"),
320
+
Self::AuthFactorTokenRequired => Cow::Borrowed("AuthFactorTokenRequired"),
321
+
Self::LegacyLoginBlocked => Cow::Borrowed("MfaRequired"),
316
322
}
317
323
}
318
324
fn message(&self) -> Option<String> {
···
436
442
Self::InvalidEmail => Some("Please provide a valid email address".to_string()),
437
443
Self::InvalidInviteCode => Some("The invite code provided is invalid".to_string()),
438
444
Self::DuplicateCreate => Some("Account creation failed: duplicate request".to_string()),
445
+
Self::LegacyLoginBlocked => Some(
446
+
"This account requires MFA. Please use an OAuth client that supports TOTP verification.".to_string(),
447
+
),
448
+
Self::AuthFactorTokenRequired => {
449
+
Some("A sign in code has been sent to your email address".to_string())
450
+
}
439
451
_ => None,
440
452
}
441
453
}
+102
-40
crates/tranquil-pds/src/api/server/email.rs
+102
-40
crates/tranquil-pds/src/api/server/email.rs
···
66
66
.log_db_err("getting email info")?
67
67
.ok_or(ApiError::AccountNotFound)?;
68
68
69
-
let Some(current_email) = user.email else {
69
+
let Some(_current_email) = user.email else {
70
70
return Err(ApiError::InvalidRequest(
71
71
"account does not have an email address".into(),
72
72
));
···
75
75
let token_required = user.email_verified;
76
76
77
77
if token_required {
78
-
let code = crate::auth::verification_token::generate_channel_update_token(
79
-
&auth.did,
80
-
"email_update",
81
-
¤t_email.to_lowercase(),
82
-
);
83
-
let formatted_code = crate::auth::verification_token::format_token_for_display(&code);
78
+
let token = crate::auth::email_token::create_email_token(
79
+
state.cache.as_ref(),
80
+
auth.did.as_str(),
81
+
crate::auth::email_token::EmailTokenPurpose::UpdateEmail,
82
+
)
83
+
.await
84
+
.map_err(|e| {
85
+
error!("Failed to create email update token: {:?}", e);
86
+
ApiError::InternalError(Some("Failed to generate verification code".into()))
87
+
})?;
84
88
85
89
if let Some(Json(ref inp)) = input
86
90
&& let Some(ref new_email) = inp.new_email
···
89
93
if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) {
90
94
let pending = PendingEmailUpdate {
91
95
new_email,
92
-
token_hash: hash_token(&code),
96
+
token_hash: hash_token(&token),
93
97
authorized: false,
94
98
};
95
99
if let Ok(json) = serde_json::to_string(&pending) {
···
102
106
}
103
107
104
108
let hostname = pds_hostname();
105
-
if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token(
109
+
if let Err(e) = crate::comms::comms_repo::enqueue_short_token_email(
106
110
state.user_repo.as_ref(),
107
111
state.infra_repo.as_ref(),
108
112
user.id,
109
-
&code,
110
-
&formatted_code,
113
+
&token,
114
+
"email_update",
111
115
hostname,
112
116
)
113
117
.await
···
239
243
));
240
244
}
241
245
242
-
if let Some(ref current) = current_email
243
-
&& new_email == current.to_lowercase()
244
-
{
246
+
let email_unchanged = current_email
247
+
.as_ref()
248
+
.map(|c| new_email == c.to_lowercase())
249
+
.unwrap_or(false);
250
+
251
+
if email_unchanged {
252
+
if let Some(email_auth_factor) = input.email_auth_factor {
253
+
if email_verified {
254
+
let token = input
255
+
.token
256
+
.as_ref()
257
+
.filter(|t| !t.is_empty())
258
+
.ok_or(ApiError::TokenRequired)?;
259
+
260
+
crate::auth::email_token::validate_email_token(
261
+
state.cache.as_ref(),
262
+
did.as_str(),
263
+
crate::auth::email_token::EmailTokenPurpose::UpdateEmail,
264
+
token,
265
+
)
266
+
.await
267
+
.map_err(|e| match e {
268
+
crate::auth::email_token::TokenError::ExpiredToken => {
269
+
ApiError::ExpiredToken(None)
270
+
}
271
+
_ => ApiError::InvalidToken(None),
272
+
})?;
273
+
}
274
+
275
+
state
276
+
.infra_repo
277
+
.upsert_account_preference(user_id, "email_auth_factor", json!(email_auth_factor))
278
+
.await
279
+
.map_err(|e| {
280
+
error!("Failed to update email_auth_factor preference: {}", e);
281
+
ApiError::InternalError(Some("Failed to update 2FA setting".into()))
282
+
})?;
283
+
}
245
284
return Ok(EmptyResponse::ok().into_response());
246
285
}
247
286
···
260
299
}
261
300
262
301
if !authorized_via_link {
263
-
let Some(ref t) = input.token else {
264
-
return Err(ApiError::TokenRequired);
265
-
};
266
-
let confirmation_token =
267
-
crate::auth::verification_token::normalize_token_input(t.trim());
268
-
269
-
let current_email_lower = current_email
302
+
let token = input
303
+
.token
270
304
.as_ref()
271
-
.map(|e| e.to_lowercase())
272
-
.unwrap_or_default();
273
-
274
-
let verified = crate::auth::verification_token::verify_channel_update_token(
275
-
&confirmation_token,
276
-
"email_update",
277
-
¤t_email_lower,
278
-
);
279
-
280
-
match verified {
281
-
Ok(token_data) => {
282
-
if token_data.did != did.as_str() {
283
-
return Err(ApiError::InvalidToken(None));
305
+
.filter(|t| !t.is_empty())
306
+
.ok_or(ApiError::TokenRequired)?;
307
+
308
+
let short_token_result = crate::auth::email_token::validate_email_token(
309
+
state.cache.as_ref(),
310
+
did.as_str(),
311
+
crate::auth::email_token::EmailTokenPurpose::UpdateEmail,
312
+
token,
313
+
)
314
+
.await;
315
+
316
+
if let Err(e) = short_token_result {
317
+
let confirmation_token =
318
+
crate::auth::verification_token::normalize_token_input(token.trim());
319
+
320
+
let current_email_lower = current_email
321
+
.as_ref()
322
+
.map(|e| e.to_lowercase())
323
+
.unwrap_or_default();
324
+
325
+
let verified = crate::auth::verification_token::verify_channel_update_token(
326
+
&confirmation_token,
327
+
"email_update",
328
+
¤t_email_lower,
329
+
);
330
+
331
+
match verified {
332
+
Ok(token_data) => {
333
+
if token_data.did != did.as_str() {
334
+
return Err(ApiError::InvalidToken(None));
335
+
}
336
+
}
337
+
Err(crate::auth::verification_token::VerifyError::Expired) => {
338
+
return Err(match e {
339
+
crate::auth::email_token::TokenError::ExpiredToken => {
340
+
ApiError::ExpiredToken(None)
341
+
}
342
+
_ => ApiError::InvalidToken(None),
343
+
});
344
+
}
345
+
Err(_) => {
346
+
return Err(match e {
347
+
crate::auth::email_token::TokenError::ExpiredToken => {
348
+
ApiError::ExpiredToken(None)
349
+
}
350
+
_ => ApiError::InvalidToken(None),
351
+
});
284
352
}
285
-
}
286
-
Err(crate::auth::verification_token::VerifyError::Expired) => {
287
-
return Err(ApiError::ExpiredToken(None));
288
-
}
289
-
Err(_) => {
290
-
return Err(ApiError::InvalidToken(None));
291
353
}
292
354
}
293
355
}
+88
-12
crates/tranquil-pds/src/api/server/session.rs
+88
-12
crates/tranquil-pds/src/api/server/session.rs
···
32
32
pub password: PlainPassword,
33
33
#[serde(default)]
34
34
pub allow_takendown: bool,
35
+
pub auth_factor_token: Option<String>,
35
36
}
36
37
37
38
#[derive(Serialize)]
···
48
49
#[serde(skip_serializing_if = "Option::is_none")]
49
50
pub email_confirmed: Option<bool>,
50
51
#[serde(skip_serializing_if = "Option::is_none")]
52
+
pub email_auth_factor: Option<bool>,
53
+
#[serde(skip_serializing_if = "Option::is_none")]
51
54
pub active: Option<bool>,
52
55
#[serde(skip_serializing_if = "Option::is_none")]
53
56
pub status: Option<String>,
···
158
161
.into_response();
159
162
}
160
163
let has_totp = row.totp_enabled;
161
-
let is_legacy_login = has_totp;
162
-
if has_totp && !row.allow_legacy_login {
163
-
warn!("Legacy login blocked for TOTP-enabled account: {}", row.did);
164
-
return (
165
-
StatusCode::FORBIDDEN,
166
-
Json(json!({
167
-
"error": "MfaRequired",
168
-
"message": "This account requires MFA. Please use an OAuth client that supports TOTP verification.",
169
-
"did": row.did
170
-
})),
171
-
)
172
-
.into_response();
164
+
let email_2fa_enabled = row.email_2fa_enabled;
165
+
let is_legacy_login = has_totp || email_2fa_enabled;
166
+
let twofa_ctx = crate::auth::legacy_2fa::Legacy2faContext {
167
+
email_2fa_enabled,
168
+
has_totp,
169
+
allow_legacy_login: row.allow_legacy_login,
170
+
};
171
+
match crate::auth::legacy_2fa::process_legacy_2fa(
172
+
state.cache.as_ref(),
173
+
&row.did,
174
+
&twofa_ctx,
175
+
input.auth_factor_token.as_deref(),
176
+
)
177
+
.await
178
+
{
179
+
Ok(crate::auth::legacy_2fa::Legacy2faOutcome::NotRequired) => {}
180
+
Ok(crate::auth::legacy_2fa::Legacy2faOutcome::Blocked) => {
181
+
warn!("Legacy login blocked for TOTP-enabled account: {}", row.did);
182
+
return ApiError::LegacyLoginBlocked.into_response();
183
+
}
184
+
Ok(crate::auth::legacy_2fa::Legacy2faOutcome::ChallengeSent(code)) => {
185
+
let hostname = pds_hostname();
186
+
if let Err(e) = crate::comms::comms_repo::enqueue_2fa_code(
187
+
state.user_repo.as_ref(),
188
+
state.infra_repo.as_ref(),
189
+
row.id,
190
+
code.as_str(),
191
+
hostname,
192
+
)
193
+
.await
194
+
{
195
+
error!("Failed to send 2FA code: {:?}", e);
196
+
crate::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &row.did).await;
197
+
return ApiError::InternalError(Some(
198
+
"Failed to send verification code. Please try again.".into(),
199
+
))
200
+
.into_response();
201
+
}
202
+
return ApiError::AuthFactorTokenRequired.into_response();
203
+
}
204
+
Ok(crate::auth::legacy_2fa::Legacy2faOutcome::Verified) => {}
205
+
Err(crate::auth::legacy_2fa::Legacy2faFlowError::Challenge(e)) => {
206
+
use crate::auth::legacy_2fa::ChallengeError;
207
+
return match e {
208
+
ChallengeError::CacheUnavailable => {
209
+
error!("Cache unavailable for 2FA, blocking legacy login");
210
+
ApiError::ServiceUnavailable(Some(
211
+
"2FA service temporarily unavailable. Please try again later or use an OAuth client.".into(),
212
+
))
213
+
.into_response()
214
+
}
215
+
ChallengeError::RateLimited => ApiError::RateLimitExceeded(Some(
216
+
"Please wait before requesting a new verification code.".into(),
217
+
))
218
+
.into_response(),
219
+
ChallengeError::CacheError => {
220
+
error!("Cache error during 2FA challenge creation");
221
+
ApiError::InternalError(None).into_response()
222
+
}
223
+
};
224
+
}
225
+
Err(crate::auth::legacy_2fa::Legacy2faFlowError::Validation(e)) => {
226
+
use crate::auth::legacy_2fa::ValidationError;
227
+
warn!("Invalid 2FA code for {}: {:?}", row.did, e);
228
+
let msg = match e {
229
+
ValidationError::TooManyAttempts => "Too many attempts. Please request a new code.",
230
+
ValidationError::ChallengeExpired => "Code has expired. Please request a new code.",
231
+
ValidationError::CacheUnavailable => {
232
+
"2FA service temporarily unavailable. Please try again later."
233
+
}
234
+
ValidationError::ChallengeNotFound
235
+
| ValidationError::InvalidCode
236
+
| ValidationError::CacheError => "Invalid verification code",
237
+
};
238
+
return ApiError::InvalidCode(Some(msg.into())).into_response();
239
+
}
173
240
}
174
241
let access_meta = match crate::auth::create_access_token_with_delegation(
175
242
&row.did,
···
236
303
let handle = full_handle(&row.handle, pds_host);
237
304
let is_active = account_state.is_active();
238
305
let status = account_state.status_for_session().map(String::from);
306
+
let email_auth_factor_out = if email_2fa_enabled || has_totp {
307
+
Some(true)
308
+
} else {
309
+
None
310
+
};
239
311
Json(CreateSessionOutput {
240
312
access_jwt: access_meta.token,
241
313
refresh_jwt: refresh_meta.token,
···
244
316
did_doc,
245
317
email: row.email,
246
318
email_confirmed: Some(row.channel_verification.email),
319
+
email_auth_factor: email_auth_factor_out,
247
320
active: Some(is_active),
248
321
status,
249
322
})
···
301
374
response["email"] = json!(email_value);
302
375
response["emailConfirmed"] = json!(email_confirmed_value);
303
376
}
377
+
if row.email_2fa_enabled || row.totp_enabled {
378
+
response["emailAuthFactor"] = json!(true);
379
+
}
304
380
if let Some(status) = account_state.status_for_session() {
305
381
response["status"] = json!(status);
306
382
}
+2
crates/tranquil-pds/src/api/server/totp.rs
+2
crates/tranquil-pds/src/api/server/totp.rs
···
187
187
.await
188
188
.log_db_err("deleting TOTP")?;
189
189
190
+
crate::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &auth.did).await;
191
+
190
192
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
191
193
192
194
Ok(EmptyResponse::ok().into_response())
+303
crates/tranquil-pds/src/auth/email_token.rs
+303
crates/tranquil-pds/src/auth/email_token.rs
···
1
+
use rand::Rng;
2
+
use serde::{Deserialize, Serialize};
3
+
use std::time::Duration;
4
+
5
+
use crate::cache::Cache;
6
+
7
+
const TOKEN_TTL_SECS: u64 = 900;
8
+
const BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
9
+
10
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11
+
pub enum EmailTokenPurpose {
12
+
UpdateEmail,
13
+
ConfirmEmail,
14
+
DeleteAccount,
15
+
ResetPassword,
16
+
PlcOperation,
17
+
}
18
+
19
+
impl EmailTokenPurpose {
20
+
fn as_str(&self) -> &'static str {
21
+
match self {
22
+
Self::UpdateEmail => "update_email",
23
+
Self::ConfirmEmail => "confirm_email",
24
+
Self::DeleteAccount => "delete_account",
25
+
Self::ResetPassword => "reset_password",
26
+
Self::PlcOperation => "plc_operation",
27
+
}
28
+
}
29
+
}
30
+
31
+
#[derive(Debug, Clone, Serialize, Deserialize)]
32
+
struct TokenData {
33
+
token: String,
34
+
created_at: u64,
35
+
}
36
+
37
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
+
pub enum TokenError {
39
+
CacheUnavailable,
40
+
CacheError,
41
+
InvalidToken,
42
+
ExpiredToken,
43
+
}
44
+
45
+
fn cache_key(did: &str, purpose: EmailTokenPurpose) -> String {
46
+
format!("email_token:{}:{}", purpose.as_str(), did)
47
+
}
48
+
49
+
fn generate_short_token() -> String {
50
+
let mut rng = rand::thread_rng();
51
+
let token: String = (0..10)
52
+
.map(|_| BASE32_CHARS[rng.gen_range(0..BASE32_CHARS.len())] as char)
53
+
.collect();
54
+
format!("{}-{}", &token[0..5], &token[5..10])
55
+
}
56
+
57
+
fn current_timestamp() -> u64 {
58
+
chrono::Utc::now().timestamp().max(0) as u64
59
+
}
60
+
61
+
pub async fn create_email_token(
62
+
cache: &dyn Cache,
63
+
did: &str,
64
+
purpose: EmailTokenPurpose,
65
+
) -> Result<String, TokenError> {
66
+
if !cache.is_available() {
67
+
return Err(TokenError::CacheUnavailable);
68
+
}
69
+
70
+
let token = generate_short_token();
71
+
let data = TokenData {
72
+
token: token.clone(),
73
+
created_at: current_timestamp(),
74
+
};
75
+
76
+
let json = serde_json::to_string(&data).map_err(|_| TokenError::CacheError)?;
77
+
78
+
cache
79
+
.set(
80
+
&cache_key(did, purpose),
81
+
&json,
82
+
Duration::from_secs(TOKEN_TTL_SECS),
83
+
)
84
+
.await
85
+
.map_err(|_| TokenError::CacheError)?;
86
+
87
+
Ok(token)
88
+
}
89
+
90
+
pub async fn validate_email_token(
91
+
cache: &dyn Cache,
92
+
did: &str,
93
+
purpose: EmailTokenPurpose,
94
+
token: &str,
95
+
) -> Result<(), TokenError> {
96
+
if !cache.is_available() {
97
+
return Err(TokenError::CacheUnavailable);
98
+
}
99
+
100
+
let key = cache_key(did, purpose);
101
+
let json = cache.get(&key).await.ok_or(TokenError::InvalidToken)?;
102
+
103
+
let data: TokenData = serde_json::from_str(&json).map_err(|_| TokenError::InvalidToken)?;
104
+
105
+
let elapsed = current_timestamp().saturating_sub(data.created_at);
106
+
if elapsed > TOKEN_TTL_SECS {
107
+
let _ = cache.delete(&key).await;
108
+
return Err(TokenError::ExpiredToken);
109
+
}
110
+
111
+
let normalized_input = token.to_uppercase().replace('-', "");
112
+
let normalized_stored = data.token.to_uppercase().replace('-', "");
113
+
114
+
if !constant_time_eq(normalized_input.as_bytes(), normalized_stored.as_bytes()) {
115
+
return Err(TokenError::InvalidToken);
116
+
}
117
+
118
+
let _ = cache.delete(&key).await;
119
+
120
+
Ok(())
121
+
}
122
+
123
+
pub async fn delete_email_token(cache: &dyn Cache, did: &str, purpose: EmailTokenPurpose) {
124
+
let _ = cache.delete(&cache_key(did, purpose)).await;
125
+
}
126
+
127
+
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
128
+
if a.len() != b.len() {
129
+
return false;
130
+
}
131
+
a.iter()
132
+
.zip(b.iter())
133
+
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
134
+
== 0
135
+
}
136
+
137
+
#[cfg(test)]
138
+
mod tests {
139
+
use super::*;
140
+
use crate::cache::CacheError;
141
+
use async_trait::async_trait;
142
+
use std::collections::HashMap;
143
+
use std::sync::Mutex;
144
+
145
+
struct MockCache {
146
+
data: Mutex<HashMap<String, (String, u64)>>,
147
+
}
148
+
149
+
impl MockCache {
150
+
fn new() -> Self {
151
+
Self {
152
+
data: Mutex::new(HashMap::new()),
153
+
}
154
+
}
155
+
}
156
+
157
+
#[async_trait]
158
+
impl Cache for MockCache {
159
+
async fn get(&self, key: &str) -> Option<String> {
160
+
let data = self.data.lock().unwrap();
161
+
let now = current_timestamp();
162
+
data.get(key)
163
+
.filter(|(_, exp)| *exp > now)
164
+
.map(|(v, _)| v.clone())
165
+
}
166
+
167
+
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
168
+
let mut data = self.data.lock().unwrap();
169
+
let expires = current_timestamp() + ttl.as_secs();
170
+
data.insert(key.to_string(), (value.to_string(), expires));
171
+
Ok(())
172
+
}
173
+
174
+
async fn delete(&self, key: &str) -> Result<(), CacheError> {
175
+
let mut data = self.data.lock().unwrap();
176
+
data.remove(key);
177
+
Ok(())
178
+
}
179
+
180
+
async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
181
+
None
182
+
}
183
+
184
+
async fn set_bytes(
185
+
&self,
186
+
_key: &str,
187
+
_value: &[u8],
188
+
_ttl: Duration,
189
+
) -> Result<(), CacheError> {
190
+
Ok(())
191
+
}
192
+
193
+
fn is_available(&self) -> bool {
194
+
true
195
+
}
196
+
}
197
+
198
+
#[tokio::test]
199
+
async fn test_create_and_validate_token() {
200
+
let cache = MockCache::new();
201
+
let did = "did:plc:test123";
202
+
203
+
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
204
+
.await
205
+
.unwrap();
206
+
207
+
assert_eq!(token.len(), 11);
208
+
assert!(token.contains('-'));
209
+
210
+
let result =
211
+
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &token).await;
212
+
assert!(result.is_ok());
213
+
}
214
+
215
+
#[tokio::test]
216
+
async fn test_token_consumed_after_use() {
217
+
let cache = MockCache::new();
218
+
let did = "did:plc:test123";
219
+
220
+
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
221
+
.await
222
+
.unwrap();
223
+
224
+
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &token)
225
+
.await
226
+
.unwrap();
227
+
228
+
let result =
229
+
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &token).await;
230
+
assert_eq!(result.unwrap_err(), TokenError::InvalidToken);
231
+
}
232
+
233
+
#[tokio::test]
234
+
async fn test_invalid_token_rejected() {
235
+
let cache = MockCache::new();
236
+
let did = "did:plc:test123";
237
+
238
+
let _token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
239
+
.await
240
+
.unwrap();
241
+
242
+
let result =
243
+
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, "XXXXX-XXXXX").await;
244
+
assert_eq!(result.unwrap_err(), TokenError::InvalidToken);
245
+
}
246
+
247
+
#[tokio::test]
248
+
async fn test_wrong_purpose_rejected() {
249
+
let cache = MockCache::new();
250
+
let did = "did:plc:test123";
251
+
252
+
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
253
+
.await
254
+
.unwrap();
255
+
256
+
let result =
257
+
validate_email_token(&cache, did, EmailTokenPurpose::ConfirmEmail, &token).await;
258
+
assert_eq!(result.unwrap_err(), TokenError::InvalidToken);
259
+
}
260
+
261
+
#[tokio::test]
262
+
async fn test_token_format() {
263
+
(0..100).for_each(|_| {
264
+
let token = generate_short_token();
265
+
assert_eq!(token.len(), 11);
266
+
assert_eq!(&token[5..6], "-");
267
+
assert!(
268
+
token[0..5]
269
+
.chars()
270
+
.all(|c| BASE32_CHARS.contains(&(c as u8)))
271
+
);
272
+
assert!(
273
+
token[6..11]
274
+
.chars()
275
+
.all(|c| BASE32_CHARS.contains(&(c as u8)))
276
+
);
277
+
});
278
+
}
279
+
280
+
#[tokio::test]
281
+
async fn test_case_insensitive_validation() {
282
+
let cache = MockCache::new();
283
+
let did = "did:plc:test123";
284
+
285
+
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
286
+
.await
287
+
.unwrap();
288
+
289
+
let lowercase = token.to_lowercase();
290
+
let result =
291
+
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &lowercase).await;
292
+
assert!(result.is_ok());
293
+
}
294
+
295
+
#[tokio::test]
296
+
async fn test_noop_cache_returns_unavailable() {
297
+
let cache = crate::cache::NoOpCache;
298
+
let did = "did:plc:test";
299
+
300
+
let result = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail).await;
301
+
assert_eq!(result.unwrap_err(), TokenError::CacheUnavailable);
302
+
}
303
+
}
+514
crates/tranquil-pds/src/auth/legacy_2fa.rs
+514
crates/tranquil-pds/src/auth/legacy_2fa.rs
···
1
+
use chrono::Utc;
2
+
use rand::Rng;
3
+
use serde::{Deserialize, Serialize};
4
+
use std::time::Duration;
5
+
6
+
use crate::cache::Cache;
7
+
use crate::types::Did;
8
+
9
+
const CHALLENGE_TTL_SECS: u64 = 300;
10
+
const MIN_REMAINING_TTL_SECS: u64 = 10;
11
+
const MAX_ATTEMPTS: u8 = 5;
12
+
const CODE_LENGTH: usize = 8;
13
+
const COOLDOWN_SECS: u64 = 60;
14
+
15
+
#[derive(Debug, Clone, Serialize, Deserialize)]
16
+
struct ChallengeData {
17
+
code: String,
18
+
attempts: u8,
19
+
created_at: u64,
20
+
}
21
+
22
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23
+
pub enum ChallengeError {
24
+
CacheUnavailable,
25
+
RateLimited,
26
+
CacheError,
27
+
}
28
+
29
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
+
pub enum ValidationError {
31
+
InvalidCode,
32
+
TooManyAttempts,
33
+
ChallengeNotFound,
34
+
ChallengeExpired,
35
+
CacheUnavailable,
36
+
CacheError,
37
+
}
38
+
39
+
#[derive(Debug)]
40
+
pub struct ChallengeCode(String);
41
+
42
+
impl ChallengeCode {
43
+
pub fn as_str(&self) -> &str {
44
+
&self.0
45
+
}
46
+
}
47
+
48
+
impl std::fmt::Display for ChallengeCode {
49
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50
+
write!(f, "{}", self.0)
51
+
}
52
+
}
53
+
54
+
pub async fn create_challenge(
55
+
cache: &dyn Cache,
56
+
did: &Did,
57
+
) -> Result<ChallengeCode, ChallengeError> {
58
+
create_challenge_code(cache, did).await
59
+
}
60
+
61
+
pub async fn clear_challenge(cache: &dyn Cache, did: &Did) {
62
+
let _ = cache.delete(&challenge_key(did.as_str())).await;
63
+
let _ = cache.delete(&cooldown_key(did.as_str())).await;
64
+
}
65
+
66
+
async fn validate_challenge_internal(
67
+
cache: &dyn Cache,
68
+
did: &str,
69
+
code: &str,
70
+
) -> Result<(), ValidationError> {
71
+
if !cache.is_available() {
72
+
return Err(ValidationError::CacheUnavailable);
73
+
}
74
+
75
+
let challenge_k = challenge_key(did);
76
+
77
+
let json = cache
78
+
.get(&challenge_k)
79
+
.await
80
+
.ok_or(ValidationError::ChallengeNotFound)?;
81
+
82
+
let data: ChallengeData =
83
+
serde_json::from_str(&json).map_err(|_| ValidationError::ChallengeNotFound)?;
84
+
85
+
if data.attempts >= MAX_ATTEMPTS {
86
+
let _ = cache.delete(&challenge_k).await;
87
+
return Err(ValidationError::TooManyAttempts);
88
+
}
89
+
90
+
let elapsed = current_timestamp().saturating_sub(data.created_at);
91
+
let remaining_ttl = CHALLENGE_TTL_SECS.saturating_sub(elapsed);
92
+
if remaining_ttl < MIN_REMAINING_TTL_SECS {
93
+
let _ = cache.delete(&challenge_k).await;
94
+
return Err(ValidationError::ChallengeExpired);
95
+
}
96
+
97
+
if !constant_time_eq(code.as_bytes(), data.code.as_bytes()) {
98
+
let updated = ChallengeData {
99
+
code: data.code,
100
+
attempts: data.attempts + 1,
101
+
created_at: data.created_at,
102
+
};
103
+
let updated_json =
104
+
serde_json::to_string(&updated).map_err(|_| ValidationError::CacheError)?;
105
+
cache
106
+
.set(
107
+
&challenge_k,
108
+
&updated_json,
109
+
Duration::from_secs(remaining_ttl),
110
+
)
111
+
.await
112
+
.map_err(|_| ValidationError::CacheError)?;
113
+
return Err(ValidationError::InvalidCode);
114
+
}
115
+
116
+
let _ = cache.delete(&challenge_k).await;
117
+
let _ = cache.delete(&cooldown_key(did)).await;
118
+
119
+
Ok(())
120
+
}
121
+
122
+
fn challenge_key(did: &str) -> String {
123
+
format!("legacy_2fa:{}", did)
124
+
}
125
+
126
+
fn cooldown_key(did: &str) -> String {
127
+
format!("legacy_2fa_cooldown:{}", did)
128
+
}
129
+
130
+
fn generate_code() -> String {
131
+
let mut rng = rand::thread_rng();
132
+
(0..CODE_LENGTH)
133
+
.map(|_| rng.gen_range(0..10).to_string())
134
+
.collect()
135
+
}
136
+
137
+
fn current_timestamp() -> u64 {
138
+
Utc::now().timestamp().max(0) as u64
139
+
}
140
+
141
+
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
142
+
if a.len() != b.len() {
143
+
return false;
144
+
}
145
+
a.iter()
146
+
.zip(b.iter())
147
+
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
148
+
== 0
149
+
}
150
+
151
+
pub enum Legacy2faOutcome {
152
+
NotRequired,
153
+
Blocked,
154
+
ChallengeSent(ChallengeCode),
155
+
Verified,
156
+
}
157
+
158
+
pub struct Legacy2faContext {
159
+
pub email_2fa_enabled: bool,
160
+
pub has_totp: bool,
161
+
pub allow_legacy_login: bool,
162
+
}
163
+
164
+
impl Legacy2faContext {
165
+
pub fn requires_2fa(&self) -> bool {
166
+
self.email_2fa_enabled || self.has_totp
167
+
}
168
+
169
+
pub fn is_blocked(&self) -> bool {
170
+
self.has_totp && !self.allow_legacy_login && !self.email_2fa_enabled
171
+
}
172
+
}
173
+
174
+
pub async fn process_legacy_2fa(
175
+
cache: &dyn Cache,
176
+
did: &Did,
177
+
ctx: &Legacy2faContext,
178
+
auth_factor_token: Option<&str>,
179
+
) -> Result<Legacy2faOutcome, Legacy2faFlowError> {
180
+
if !ctx.requires_2fa() {
181
+
return Ok(Legacy2faOutcome::NotRequired);
182
+
}
183
+
184
+
if ctx.is_blocked() {
185
+
return Ok(Legacy2faOutcome::Blocked);
186
+
}
187
+
188
+
match auth_factor_token.filter(|t| !t.is_empty()) {
189
+
None => {
190
+
let code = create_challenge_code(cache, did).await?;
191
+
Ok(Legacy2faOutcome::ChallengeSent(code))
192
+
}
193
+
Some(token) => {
194
+
validate_challenge(cache, did, token).await?;
195
+
Ok(Legacy2faOutcome::Verified)
196
+
}
197
+
}
198
+
}
199
+
200
+
pub async fn validate_challenge(
201
+
cache: &dyn Cache,
202
+
did: &Did,
203
+
code: &str,
204
+
) -> Result<(), ValidationError> {
205
+
validate_challenge_internal(cache, did.as_str(), code).await
206
+
}
207
+
208
+
async fn create_challenge_code(
209
+
cache: &dyn Cache,
210
+
did: &Did,
211
+
) -> Result<ChallengeCode, ChallengeError> {
212
+
if !cache.is_available() {
213
+
return Err(ChallengeError::CacheUnavailable);
214
+
}
215
+
216
+
let cooldown = cooldown_key(did.as_str());
217
+
if cache.get(&cooldown).await.is_some() {
218
+
return Err(ChallengeError::RateLimited);
219
+
}
220
+
221
+
let code = generate_code();
222
+
let now = current_timestamp();
223
+
224
+
let data = ChallengeData {
225
+
code: code.clone(),
226
+
attempts: 0,
227
+
created_at: now,
228
+
};
229
+
230
+
let json = serde_json::to_string(&data).map_err(|_| ChallengeError::CacheError)?;
231
+
232
+
cache
233
+
.set(
234
+
&challenge_key(did.as_str()),
235
+
&json,
236
+
Duration::from_secs(CHALLENGE_TTL_SECS),
237
+
)
238
+
.await
239
+
.map_err(|_| ChallengeError::CacheError)?;
240
+
241
+
cache
242
+
.set(&cooldown, "1", Duration::from_secs(COOLDOWN_SECS))
243
+
.await
244
+
.map_err(|_| ChallengeError::CacheError)?;
245
+
246
+
Ok(ChallengeCode(code))
247
+
}
248
+
249
+
#[derive(Debug)]
250
+
pub enum Legacy2faFlowError {
251
+
Challenge(ChallengeError),
252
+
Validation(ValidationError),
253
+
}
254
+
255
+
impl From<ChallengeError> for Legacy2faFlowError {
256
+
fn from(e: ChallengeError) -> Self {
257
+
Self::Challenge(e)
258
+
}
259
+
}
260
+
261
+
impl From<ValidationError> for Legacy2faFlowError {
262
+
fn from(e: ValidationError) -> Self {
263
+
Self::Validation(e)
264
+
}
265
+
}
266
+
267
+
#[cfg(test)]
268
+
mod tests {
269
+
use super::*;
270
+
use crate::cache::CacheError;
271
+
use async_trait::async_trait;
272
+
use std::collections::HashMap;
273
+
use std::sync::Mutex;
274
+
275
+
struct MockCache {
276
+
data: Mutex<HashMap<String, (String, u64)>>,
277
+
}
278
+
279
+
impl MockCache {
280
+
fn new() -> Self {
281
+
Self {
282
+
data: Mutex::new(HashMap::new()),
283
+
}
284
+
}
285
+
}
286
+
287
+
#[async_trait]
288
+
impl Cache for MockCache {
289
+
async fn get(&self, key: &str) -> Option<String> {
290
+
let data = self.data.lock().unwrap();
291
+
let now = current_timestamp();
292
+
data.get(key)
293
+
.filter(|(_, exp)| *exp > now)
294
+
.map(|(v, _)| v.clone())
295
+
}
296
+
297
+
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
298
+
let mut data = self.data.lock().unwrap();
299
+
let expires = current_timestamp() + ttl.as_secs();
300
+
data.insert(key.to_string(), (value.to_string(), expires));
301
+
Ok(())
302
+
}
303
+
304
+
async fn delete(&self, key: &str) -> Result<(), CacheError> {
305
+
let mut data = self.data.lock().unwrap();
306
+
data.remove(key);
307
+
Ok(())
308
+
}
309
+
310
+
async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
311
+
None
312
+
}
313
+
314
+
async fn set_bytes(
315
+
&self,
316
+
_key: &str,
317
+
_value: &[u8],
318
+
_ttl: Duration,
319
+
) -> Result<(), CacheError> {
320
+
Ok(())
321
+
}
322
+
323
+
fn is_available(&self) -> bool {
324
+
true
325
+
}
326
+
}
327
+
328
+
#[tokio::test]
329
+
async fn test_create_and_validate_challenge() {
330
+
let cache = MockCache::new();
331
+
let did = Did::new("did:plc:test123".to_string()).unwrap();
332
+
333
+
let code = create_challenge(&cache, &did).await.unwrap();
334
+
assert_eq!(code.as_str().len(), CODE_LENGTH);
335
+
336
+
let result = validate_challenge(&cache, &did, code.as_str()).await;
337
+
assert!(result.is_ok());
338
+
}
339
+
340
+
#[tokio::test]
341
+
async fn test_invalid_code_rejected() {
342
+
let cache = MockCache::new();
343
+
let did = Did::new("did:plc:test123".to_string()).unwrap();
344
+
345
+
let _code = create_challenge(&cache, &did).await.unwrap();
346
+
let result = validate_challenge(&cache, &did, "00000000").await;
347
+
assert_eq!(result.unwrap_err(), ValidationError::InvalidCode);
348
+
}
349
+
350
+
#[tokio::test]
351
+
async fn test_challenge_consumed_on_success() {
352
+
let cache = MockCache::new();
353
+
let did = Did::new("did:plc:test123".to_string()).unwrap();
354
+
355
+
let code = create_challenge(&cache, &did).await.unwrap();
356
+
validate_challenge(&cache, &did, code.as_str())
357
+
.await
358
+
.unwrap();
359
+
360
+
let result = validate_challenge(&cache, &did, code.as_str()).await;
361
+
assert_eq!(result.unwrap_err(), ValidationError::ChallengeNotFound);
362
+
}
363
+
364
+
#[tokio::test]
365
+
async fn test_max_attempts_exceeded() {
366
+
let cache = MockCache::new();
367
+
let did = Did::new("did:plc:test123".to_string()).unwrap();
368
+
369
+
let _code = create_challenge(&cache, &did).await.unwrap();
370
+
371
+
(0..MAX_ATTEMPTS).for_each(|_| {
372
+
let _ = futures::executor::block_on(validate_challenge(&cache, &did, "wrong123"));
373
+
});
374
+
375
+
let result = validate_challenge(&cache, &did, "anything").await;
376
+
assert_eq!(result.unwrap_err(), ValidationError::TooManyAttempts);
377
+
}
378
+
379
+
#[tokio::test]
380
+
async fn test_rate_limiting() {
381
+
let cache = MockCache::new();
382
+
let did = Did::new("did:plc:test123".to_string()).unwrap();
383
+
384
+
let _first = create_challenge(&cache, &did).await.unwrap();
385
+
let result = create_challenge(&cache, &did).await;
386
+
assert_eq!(result.unwrap_err(), ChallengeError::RateLimited);
387
+
}
388
+
389
+
#[tokio::test]
390
+
async fn test_noop_cache_returns_unavailable() {
391
+
let cache = crate::cache::NoOpCache;
392
+
let did = Did::new("did:plc:test".to_string()).unwrap();
393
+
394
+
let result = create_challenge(&cache, &did).await;
395
+
assert_eq!(result.unwrap_err(), ChallengeError::CacheUnavailable);
396
+
}
397
+
398
+
#[tokio::test]
399
+
async fn test_code_generation_is_numeric() {
400
+
(0..100).for_each(|_| {
401
+
let code = generate_code();
402
+
assert!(code.chars().all(|c| c.is_ascii_digit()));
403
+
assert_eq!(code.len(), CODE_LENGTH);
404
+
});
405
+
}
406
+
407
+
#[tokio::test]
408
+
async fn test_constant_time_eq() {
409
+
assert!(constant_time_eq(b"12345678", b"12345678"));
410
+
assert!(!constant_time_eq(b"12345678", b"12345679"));
411
+
assert!(!constant_time_eq(b"12345678", b"1234567"));
412
+
assert!(!constant_time_eq(b"", b"1"));
413
+
assert!(constant_time_eq(b"", b""));
414
+
}
415
+
416
+
#[tokio::test]
417
+
async fn test_process_flow_not_required() {
418
+
let cache = MockCache::new();
419
+
let did = Did::new("did:plc:test".to_string()).unwrap();
420
+
let ctx = Legacy2faContext {
421
+
email_2fa_enabled: false,
422
+
has_totp: false,
423
+
allow_legacy_login: true,
424
+
};
425
+
426
+
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
427
+
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
428
+
}
429
+
430
+
#[tokio::test]
431
+
async fn test_process_flow_blocked() {
432
+
let cache = MockCache::new();
433
+
let did = Did::new("did:plc:test".to_string()).unwrap();
434
+
let ctx = Legacy2faContext {
435
+
email_2fa_enabled: false,
436
+
has_totp: true,
437
+
allow_legacy_login: false,
438
+
};
439
+
440
+
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
441
+
assert!(matches!(outcome, Legacy2faOutcome::Blocked));
442
+
}
443
+
444
+
#[tokio::test]
445
+
async fn test_process_flow_challenge_sent_totp() {
446
+
let cache = MockCache::new();
447
+
let did = Did::new("did:plc:test".to_string()).unwrap();
448
+
let ctx = Legacy2faContext {
449
+
email_2fa_enabled: false,
450
+
has_totp: true,
451
+
allow_legacy_login: true,
452
+
};
453
+
454
+
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
455
+
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
456
+
}
457
+
458
+
#[tokio::test]
459
+
async fn test_process_flow_challenge_sent_email_2fa_enabled() {
460
+
let cache = MockCache::new();
461
+
let did = Did::new("did:plc:test2".to_string()).unwrap();
462
+
let ctx = Legacy2faContext {
463
+
email_2fa_enabled: true,
464
+
has_totp: false,
465
+
allow_legacy_login: false,
466
+
};
467
+
468
+
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
469
+
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
470
+
}
471
+
472
+
#[tokio::test]
473
+
async fn test_process_flow_verified() {
474
+
let cache = MockCache::new();
475
+
let did = Did::new("did:plc:test".to_string()).unwrap();
476
+
let ctx = Legacy2faContext {
477
+
email_2fa_enabled: true,
478
+
has_totp: false,
479
+
allow_legacy_login: false,
480
+
};
481
+
482
+
let code = create_challenge(&cache, &did).await.unwrap();
483
+
484
+
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()))
485
+
.await
486
+
.unwrap();
487
+
assert!(matches!(outcome, Legacy2faOutcome::Verified));
488
+
}
489
+
490
+
#[tokio::test]
491
+
async fn test_attempts_persist_across_failures() {
492
+
let cache = MockCache::new();
493
+
let did = Did::new("did:plc:test123".to_string()).unwrap();
494
+
495
+
let code = create_challenge(&cache, &did).await.unwrap();
496
+
497
+
(0..3).for_each(|_| {
498
+
let result = futures::executor::block_on(validate_challenge(&cache, &did, "wrong123"));
499
+
assert_eq!(result.unwrap_err(), ValidationError::InvalidCode);
500
+
});
501
+
502
+
let result = validate_challenge(&cache, &did, code.as_str()).await;
503
+
assert!(result.is_ok());
504
+
}
505
+
506
+
#[tokio::test]
507
+
async fn test_validation_on_noop_cache_returns_unavailable() {
508
+
let cache = crate::cache::NoOpCache;
509
+
let did = Did::new("did:plc:test".to_string()).unwrap();
510
+
511
+
let result = validate_challenge(&cache, &did, "12345678").await;
512
+
assert_eq!(result.unwrap_err(), ValidationError::CacheUnavailable);
513
+
}
514
+
}
+2
crates/tranquil-pds/src/auth/mod.rs
+2
crates/tranquil-pds/src/auth/mod.rs
+51
crates/tranquil-pds/src/comms/service.rs
+51
crates/tranquil-pds/src/comms/service.rs
···
403
403
.await
404
404
}
405
405
406
+
pub async fn enqueue_short_token_email(
407
+
user_repo: &dyn UserRepository,
408
+
infra_repo: &dyn InfraRepository,
409
+
user_id: Uuid,
410
+
token: &str,
411
+
purpose: &str,
412
+
hostname: &str,
413
+
) -> Result<Uuid, DbError> {
414
+
let prefs = user_repo
415
+
.get_comms_prefs(user_id)
416
+
.await?
417
+
.ok_or(DbError::NotFound)?;
418
+
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
419
+
let current_email = prefs.email.clone().unwrap_or_default();
420
+
421
+
let (subject_template, body_template, comms_type) = match purpose {
422
+
"email_update" => (
423
+
strings.email_update_subject,
424
+
strings.short_token_body,
425
+
CommsType::EmailUpdate,
426
+
),
427
+
_ => (
428
+
strings.email_update_subject,
429
+
strings.short_token_body,
430
+
CommsType::EmailUpdate,
431
+
),
432
+
};
433
+
434
+
let verify_page = format!("https://{}/app/settings", hostname);
435
+
let body = format_message(
436
+
body_template,
437
+
&[
438
+
("handle", &prefs.handle),
439
+
("code", token),
440
+
("verify_page", &verify_page),
441
+
],
442
+
);
443
+
let subject = format_message(subject_template, &[("hostname", hostname)]);
444
+
infra_repo
445
+
.enqueue_comms(
446
+
Some(user_id),
447
+
tranquil_db_traits::CommsChannel::Email,
448
+
comms_type,
449
+
¤t_email,
450
+
Some(&subject),
451
+
&body,
452
+
None,
453
+
)
454
+
.await
455
+
}
456
+
406
457
pub async fn enqueue_account_deletion(
407
458
user_repo: &dyn UserRepository,
408
459
infra_repo: &dyn InfraRepository,
+6
-4
crates/tranquil-pds/tests/actor.rs
+6
-4
crates/tranquil-pds/tests/actor.rs
···
115
115
let body: Value = resp.json().await.unwrap();
116
116
let prefs_arr = body["preferences"].as_array().unwrap();
117
117
assert_eq!(prefs_arr.len(), 3);
118
-
let adult_pref = prefs_arr
119
-
.iter()
120
-
.find(|p| p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#adultContentPref"));
118
+
let adult_pref = prefs_arr.iter().find(|p| {
119
+
p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#adultContentPref")
120
+
});
121
121
assert!(adult_pref.is_some());
122
122
assert_eq!(adult_pref.unwrap()["enabled"], false);
123
123
let content_label_prefs: Vec<&Value> = prefs_arr
124
124
.iter()
125
-
.filter(|p| p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#contentLabelPref"))
125
+
.filter(|p| {
126
+
p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#contentLabelPref")
127
+
})
126
128
.collect();
127
129
assert_eq!(content_label_prefs.len(), 2);
128
130
let dogs_pref = content_label_prefs
+481
crates/tranquil-pds/tests/legacy_2fa.rs
+481
crates/tranquil-pds/tests/legacy_2fa.rs
···
1
+
mod common;
2
+
3
+
use common::{base_url, client, create_account_and_login, get_test_db_pool};
4
+
use reqwest::StatusCode;
5
+
use serde_json::{Value, json};
6
+
7
+
async fn enable_totp_for_user(did: &str) {
8
+
let pool = get_test_db_pool().await;
9
+
let secret = vec![0u8; 20];
10
+
sqlx::query(
11
+
r#"INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)
12
+
VALUES ($1, $2, 1, TRUE, NOW())
13
+
ON CONFLICT (did) DO UPDATE SET verified = TRUE"#,
14
+
)
15
+
.bind(did)
16
+
.bind(&secret)
17
+
.execute(pool)
18
+
.await
19
+
.expect("Failed to enable TOTP");
20
+
}
21
+
22
+
async fn set_allow_legacy_login(did: &str, allow: bool) {
23
+
let pool = get_test_db_pool().await;
24
+
sqlx::query("UPDATE users SET allow_legacy_login = $1 WHERE did = $2")
25
+
.bind(allow)
26
+
.bind(did)
27
+
.execute(pool)
28
+
.await
29
+
.expect("Failed to set allow_legacy_login");
30
+
}
31
+
32
+
async fn get_2fa_code_from_queue(did: &str) -> Option<String> {
33
+
let pool = get_test_db_pool().await;
34
+
let row: Option<(String,)> = sqlx::query_as(
35
+
r#"SELECT body FROM comms_queue
36
+
WHERE user_id = (SELECT id FROM users WHERE did = $1)
37
+
AND comms_type = 'two_factor_code'
38
+
ORDER BY created_at DESC LIMIT 1"#,
39
+
)
40
+
.bind(did)
41
+
.fetch_optional(pool)
42
+
.await
43
+
.ok()
44
+
.flatten();
45
+
46
+
row.and_then(|(body,)| {
47
+
body.lines()
48
+
.find(|line: &&str| line.chars().all(|c: char| c.is_ascii_digit()) && line.len() == 8)
49
+
.map(|s: &str| s.to_string())
50
+
.or_else(|| {
51
+
body.split_whitespace()
52
+
.find(|word: &&str| {
53
+
word.chars().all(|c: char| c.is_ascii_digit()) && word.len() == 8
54
+
})
55
+
.map(|s: &str| s.to_string())
56
+
})
57
+
})
58
+
}
59
+
60
+
async fn clear_2fa_challenges_for_user(did: &str) {
61
+
let pool = get_test_db_pool().await;
62
+
let _ = sqlx::query(
63
+
"DELETE FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'two_factor_code'",
64
+
)
65
+
.bind(did)
66
+
.execute(pool)
67
+
.await;
68
+
}
69
+
70
+
async fn set_email_auth_factor(did: &str, enabled: bool) {
71
+
let pool = get_test_db_pool().await;
72
+
let user_id: uuid::Uuid =
73
+
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE did = $1")
74
+
.bind(did)
75
+
.fetch_one(pool)
76
+
.await
77
+
.expect("Failed to get user id");
78
+
let pool = get_test_db_pool().await;
79
+
let _ = sqlx::query(
80
+
"DELETE FROM account_preferences WHERE user_id = $1 AND name = 'email_auth_factor'",
81
+
)
82
+
.bind(user_id)
83
+
.execute(pool)
84
+
.await;
85
+
let pool = get_test_db_pool().await;
86
+
sqlx::query(
87
+
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2::jsonb)",
88
+
)
89
+
.bind(user_id)
90
+
.bind(serde_json::json!(enabled))
91
+
.execute(pool)
92
+
.await
93
+
.expect("Failed to set email_auth_factor");
94
+
}
95
+
96
+
#[tokio::test]
97
+
async fn test_legacy_2fa_auth_factor_required() {
98
+
let client = client();
99
+
let base = base_url().await;
100
+
let (_token, did) = create_account_and_login(&client).await;
101
+
102
+
enable_totp_for_user(&did).await;
103
+
set_allow_legacy_login(&did, true).await;
104
+
105
+
let pool = get_test_db_pool().await;
106
+
let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
107
+
.bind(&did)
108
+
.fetch_one(pool)
109
+
.await
110
+
.expect("Failed to get handle");
111
+
112
+
let login_payload = json!({
113
+
"identifier": handle,
114
+
"password": "Testpass123!"
115
+
});
116
+
let resp = client
117
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
118
+
.json(&login_payload)
119
+
.send()
120
+
.await
121
+
.unwrap();
122
+
123
+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
124
+
let body: Value = resp.json().await.unwrap();
125
+
assert_eq!(body["error"], "AuthFactorTokenRequired");
126
+
assert!(
127
+
body["message"]
128
+
.as_str()
129
+
.unwrap_or("")
130
+
.contains("sign in code")
131
+
);
132
+
}
133
+
134
+
#[tokio::test]
135
+
async fn test_legacy_2fa_valid_code_succeeds() {
136
+
let client = client();
137
+
let base = base_url().await;
138
+
let (_token, did) = create_account_and_login(&client).await;
139
+
140
+
enable_totp_for_user(&did).await;
141
+
set_allow_legacy_login(&did, true).await;
142
+
clear_2fa_challenges_for_user(&did).await;
143
+
144
+
let pool = get_test_db_pool().await;
145
+
let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
146
+
.bind(&did)
147
+
.fetch_one(pool)
148
+
.await
149
+
.expect("Failed to get handle");
150
+
151
+
let login_payload = json!({
152
+
"identifier": handle,
153
+
"password": "Testpass123!"
154
+
});
155
+
let resp = client
156
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
157
+
.json(&login_payload)
158
+
.send()
159
+
.await
160
+
.unwrap();
161
+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
162
+
163
+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
164
+
let code = get_2fa_code_from_queue(&did)
165
+
.await
166
+
.expect("2FA code should be in queue");
167
+
168
+
let login_with_code = json!({
169
+
"identifier": handle,
170
+
"password": "Testpass123!",
171
+
"authFactorToken": code
172
+
});
173
+
let resp = client
174
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
175
+
.json(&login_with_code)
176
+
.send()
177
+
.await
178
+
.unwrap();
179
+
180
+
assert_eq!(resp.status(), StatusCode::OK);
181
+
let body: Value = resp.json().await.unwrap();
182
+
assert!(body.get("accessJwt").is_some());
183
+
assert!(body.get("refreshJwt").is_some());
184
+
assert_eq!(body["did"], did);
185
+
}
186
+
187
+
#[tokio::test]
188
+
async fn test_legacy_2fa_invalid_code_rejected() {
189
+
let client = client();
190
+
let base = base_url().await;
191
+
let (_token, did) = create_account_and_login(&client).await;
192
+
193
+
enable_totp_for_user(&did).await;
194
+
set_allow_legacy_login(&did, true).await;
195
+
clear_2fa_challenges_for_user(&did).await;
196
+
197
+
let pool = get_test_db_pool().await;
198
+
let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
199
+
.bind(&did)
200
+
.fetch_one(pool)
201
+
.await
202
+
.expect("Failed to get handle");
203
+
204
+
let resp = client
205
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
206
+
.json(&json!({
207
+
"identifier": handle,
208
+
"password": "Testpass123!"
209
+
}))
210
+
.send()
211
+
.await
212
+
.unwrap();
213
+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
214
+
215
+
let login_with_bad_code = json!({
216
+
"identifier": handle,
217
+
"password": "Testpass123!",
218
+
"authFactorToken": "00000000"
219
+
});
220
+
let resp = client
221
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
222
+
.json(&login_with_bad_code)
223
+
.send()
224
+
.await
225
+
.unwrap();
226
+
227
+
let status = resp.status();
228
+
let body: Value = resp.json().await.unwrap();
229
+
assert_eq!(
230
+
status,
231
+
StatusCode::BAD_REQUEST,
232
+
"Expected 400, got {}. Response: {:?}",
233
+
status,
234
+
body
235
+
);
236
+
assert_eq!(body["error"], "InvalidCode");
237
+
}
238
+
239
+
#[tokio::test]
240
+
async fn test_legacy_2fa_blocked_when_disabled() {
241
+
let client = client();
242
+
let base = base_url().await;
243
+
let (_token, did) = create_account_and_login(&client).await;
244
+
245
+
enable_totp_for_user(&did).await;
246
+
set_allow_legacy_login(&did, false).await;
247
+
248
+
let pool = get_test_db_pool().await;
249
+
let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
250
+
.bind(&did)
251
+
.fetch_one(pool)
252
+
.await
253
+
.expect("Failed to get handle");
254
+
255
+
let login_payload = json!({
256
+
"identifier": handle,
257
+
"password": "Testpass123!"
258
+
});
259
+
let resp = client
260
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
261
+
.json(&login_payload)
262
+
.send()
263
+
.await
264
+
.unwrap();
265
+
266
+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
267
+
let body: Value = resp.json().await.unwrap();
268
+
assert_eq!(body["error"], "MfaRequired");
269
+
}
270
+
271
+
#[tokio::test]
272
+
async fn test_legacy_2fa_no_totp_no_challenge() {
273
+
let client = client();
274
+
let base = base_url().await;
275
+
let (_token, did) = create_account_and_login(&client).await;
276
+
277
+
let pool = get_test_db_pool().await;
278
+
let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
279
+
.bind(&did)
280
+
.fetch_one(pool)
281
+
.await
282
+
.expect("Failed to get handle");
283
+
284
+
let login_payload = json!({
285
+
"identifier": handle,
286
+
"password": "Testpass123!"
287
+
});
288
+
let resp = client
289
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
290
+
.json(&login_payload)
291
+
.send()
292
+
.await
293
+
.unwrap();
294
+
295
+
assert_eq!(resp.status(), StatusCode::OK);
296
+
let body: Value = resp.json().await.unwrap();
297
+
assert!(body.get("accessJwt").is_some());
298
+
}
299
+
300
+
#[tokio::test]
301
+
async fn test_legacy_2fa_code_consumed_after_use() {
302
+
let client = client();
303
+
let base = base_url().await;
304
+
let (_token, did) = create_account_and_login(&client).await;
305
+
306
+
enable_totp_for_user(&did).await;
307
+
set_allow_legacy_login(&did, true).await;
308
+
clear_2fa_challenges_for_user(&did).await;
309
+
310
+
let pool = get_test_db_pool().await;
311
+
let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
312
+
.bind(&did)
313
+
.fetch_one(pool)
314
+
.await
315
+
.expect("Failed to get handle");
316
+
317
+
let resp = client
318
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
319
+
.json(&json!({
320
+
"identifier": handle,
321
+
"password": "Testpass123!"
322
+
}))
323
+
.send()
324
+
.await
325
+
.unwrap();
326
+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
327
+
328
+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
329
+
let code = get_2fa_code_from_queue(&did)
330
+
.await
331
+
.expect("2FA code should be in queue");
332
+
333
+
let resp = client
334
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
335
+
.json(&json!({
336
+
"identifier": handle,
337
+
"password": "Testpass123!",
338
+
"authFactorToken": code
339
+
}))
340
+
.send()
341
+
.await
342
+
.unwrap();
343
+
assert_eq!(resp.status(), StatusCode::OK);
344
+
345
+
clear_2fa_challenges_for_user(&did).await;
346
+
let resp = client
347
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
348
+
.json(&json!({
349
+
"identifier": handle,
350
+
"password": "Testpass123!"
351
+
}))
352
+
.send()
353
+
.await
354
+
.unwrap();
355
+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
356
+
let body: Value = resp.json().await.unwrap();
357
+
assert_eq!(body["error"], "AuthFactorTokenRequired");
358
+
359
+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
360
+
let new_code = get_2fa_code_from_queue(&did)
361
+
.await
362
+
.expect("New 2FA code should be in queue");
363
+
364
+
let resp = client
365
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
366
+
.json(&json!({
367
+
"identifier": handle,
368
+
"password": "Testpass123!",
369
+
"authFactorToken": code
370
+
}))
371
+
.send()
372
+
.await
373
+
.unwrap();
374
+
let status = resp.status();
375
+
let body: Value = resp.json().await.unwrap();
376
+
assert_eq!(
377
+
status,
378
+
StatusCode::BAD_REQUEST,
379
+
"Expected 400 for old code, got {}. Response: {:?}",
380
+
status,
381
+
body
382
+
);
383
+
assert_eq!(body["error"], "InvalidCode");
384
+
385
+
let resp = client
386
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
387
+
.json(&json!({
388
+
"identifier": handle,
389
+
"password": "Testpass123!",
390
+
"authFactorToken": new_code
391
+
}))
392
+
.send()
393
+
.await
394
+
.unwrap();
395
+
assert_eq!(resp.status(), StatusCode::OK);
396
+
}
397
+
398
+
#[tokio::test]
399
+
async fn test_email_auth_factor_requires_code() {
400
+
let client = client();
401
+
let base = base_url().await;
402
+
let (_token, did) = create_account_and_login(&client).await;
403
+
404
+
set_email_auth_factor(&did, true).await;
405
+
clear_2fa_challenges_for_user(&did).await;
406
+
407
+
let pool = get_test_db_pool().await;
408
+
let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
409
+
.bind(&did)
410
+
.fetch_one(pool)
411
+
.await
412
+
.expect("Failed to get handle");
413
+
414
+
let login_payload = json!({
415
+
"identifier": handle,
416
+
"password": "Testpass123!"
417
+
});
418
+
let resp = client
419
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
420
+
.json(&login_payload)
421
+
.send()
422
+
.await
423
+
.unwrap();
424
+
425
+
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
426
+
let body: Value = resp.json().await.unwrap();
427
+
assert_eq!(body["error"], "AuthFactorTokenRequired");
428
+
429
+
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
430
+
let code = get_2fa_code_from_queue(&did)
431
+
.await
432
+
.expect("2FA code should be in queue");
433
+
434
+
let login_with_code = json!({
435
+
"identifier": handle,
436
+
"password": "Testpass123!",
437
+
"authFactorToken": code
438
+
});
439
+
let resp = client
440
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
441
+
.json(&login_with_code)
442
+
.send()
443
+
.await
444
+
.unwrap();
445
+
446
+
assert_eq!(resp.status(), StatusCode::OK);
447
+
let body: Value = resp.json().await.unwrap();
448
+
assert!(body.get("accessJwt").is_some());
449
+
assert_eq!(body["emailAuthFactor"], true);
450
+
}
451
+
452
+
#[tokio::test]
453
+
async fn test_email_auth_factor_disabled_no_challenge() {
454
+
let client = client();
455
+
let base = base_url().await;
456
+
let (_token, did) = create_account_and_login(&client).await;
457
+
458
+
set_email_auth_factor(&did, false).await;
459
+
460
+
let pool = get_test_db_pool().await;
461
+
let handle: String = sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
462
+
.bind(&did)
463
+
.fetch_one(pool)
464
+
.await
465
+
.expect("Failed to get handle");
466
+
467
+
let login_payload = json!({
468
+
"identifier": handle,
469
+
"password": "Testpass123!"
470
+
});
471
+
let resp = client
472
+
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
473
+
.json(&login_payload)
474
+
.send()
475
+
.await
476
+
.unwrap();
477
+
478
+
assert_eq!(resp.status(), StatusCode::OK);
479
+
let body: Value = resp.json().await.unwrap();
480
+
assert!(body.get("accessJwt").is_some());
481
+
}
+1
-5
crates/tranquil-pds/tests/shutdown_unit.rs
+1
-5
crates/tranquil-pds/tests/shutdown_unit.rs
···
60
60
61
61
shutdown.cancel();
62
62
63
-
let result = tokio::time::timeout(
64
-
std::time::Duration::from_millis(100),
65
-
handle,
66
-
)
67
-
.await;
63
+
let result = tokio::time::timeout(std::time::Duration::from_millis(100), handle).await;
68
64
69
65
assert!(result.is_ok());
70
66
assert!(result.unwrap().unwrap());
History
2 rounds
2 comments
expand 0 comments
pull request successfully merged
nitpicky but id prefer email_auth_factor in crates/tranquil-db-traits/src/user.rs be called email_2fa_enabled instead. lines up with totp_enabled and is more descriptive
might wanna double check that youve run cargo fmt cause my gut tells me rustfmt wouldnt allow ) .await { and would instead do ).await { but if it does the former it does that ig ...