WIP SSO OTP bridge
This commit is contained in:
1 parent
f0a877c988
commit
4f1c9134ee
71 files changed
+3087
-19
No files matched your search
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "expired",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"max_size": 1,
|
||||
"type": "LongLong"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "1c618e9816d6f3f7f217770f988a132d9521693da6474e945628d1b3efbc4b80",
|
||||
"query": "\nSELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?\n"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "session_token",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "459898b1b42a51097dcbc0f182bd0badce642b11bd33a7e1973a6cc25538ffbf",
|
||||
"query": "\nSELECT\n token as session_token,\n user_token\nFROM user_sessions\nWHERE token = ?\nAND deleted_at IS NULL\nAND expires_at > NOW()\n "
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
}
|
||||
},
|
||||
"hash": "465853868fd16271a922960fa382fa062fc64572fc1c39529ff7dbd8951c3153",
|
||||
"query": "\nINSERT INTO user_login_challenges\n (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, created_at, expires_at)\nVALUES (?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"hash": "50b3c3734a0f679cb9308d382656c6a81b14bb5b2874fa8ce5b27d56722db4a3",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )\n "
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
}
|
||||
},
|
||||
"hash": "5d762de2f24028e9b35b2fcba76dc5680d532ea8de6443c60311821b6a3e6395",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES (?, ?, ?, ?, 'impersonation', ?)\n "
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirmation_code",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 32,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_deciding_user_token",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_redeemed_user_session_id",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "UNIQUE_KEY",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_failure_type",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_address_creation",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 160,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"max_size": 19,
|
||||
"type": "Timestamp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "7c110983a6f4836b451ef288ce375122ca25ff104c31495f9defefae0252953c",
|
||||
"query": "\nSELECT id, confirmation_code, status, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type, ip_address_creation, expires_at\nFROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE\n"
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirmation_code",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 32,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_deciding_user_token",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_redeemed_user_session_id",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "UNIQUE_KEY",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_failure_type",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_address_creation",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 160,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"max_size": 19,
|
||||
"type": "Timestamp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "860645e70bdf20ba6ee272cb506095bb5439c5b4b98e4ee8bc0e4695a057819c",
|
||||
"query": "\nSELECT id, confirmation_code, status, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type, ip_address_creation, expires_at\nFROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"hash": "86d8d6a7ffbf6880bcc6d74f31f220ac2e27d9b9b01ee98030a8f4acab849adb",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )\n "
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "8bfbb8bef3bec235f37c5f4f5c2f3a8b0833241a63da122b8abd97cd9b71208f",
|
||||
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()\n AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE\n AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_address",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_gravatar_hash",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_confirmed",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_confirmed_by_google",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_is_synthetic",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "is_without_password",
|
||||
"ordinal": 8,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username_is_not_customized",
|
||||
"ordinal": 9,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_stripe_customer_id",
|
||||
"ordinal": 10,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_loyalty_program_key",
|
||||
"ordinal": 11,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disable_gravatar",
|
||||
"ordinal": 12,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_play_audio_preference",
|
||||
"ordinal": 13,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_play_video_preference",
|
||||
"ordinal": 14,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_tts_result_visibility: enums::common::visibility::Visibility",
|
||||
"ordinal": 15,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"max_size": 28,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility",
|
||||
"ordinal": 16,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"max_size": 28,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_role_slug",
|
||||
"ordinal": 17,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "is_banned",
|
||||
"ordinal": 18,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_access_studio",
|
||||
"ordinal": 19,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_feature_flags",
|
||||
"ordinal": 20,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_use_tts",
|
||||
"ordinal": 21,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_use_w2l",
|
||||
"ordinal": 22,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_tts_results",
|
||||
"ordinal": 23,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_w2l_results",
|
||||
"ordinal": 24,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_account",
|
||||
"ordinal": 25,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_upload_tts_models",
|
||||
"ordinal": 26,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_upload_w2l_templates",
|
||||
"ordinal": 27,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_tts_models",
|
||||
"ordinal": 28,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_w2l_templates",
|
||||
"ordinal": 29,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_approve_w2l_templates",
|
||||
"ordinal": 30,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_profiles",
|
||||
"ordinal": 31,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_tts_models",
|
||||
"ordinal": 32,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_w2l_templates",
|
||||
"ordinal": 33,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_tts_models",
|
||||
"ordinal": 34,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_tts_results",
|
||||
"ordinal": 35,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_w2l_templates",
|
||||
"ordinal": 36,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_w2l_results",
|
||||
"ordinal": 37,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_ban_users",
|
||||
"ordinal": 38,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_users",
|
||||
"ordinal": 39,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "934aad794dcb0057e39c471e7b25e5e191a395a2bd16553cb58a483b91c68437",
|
||||
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_gravatar_hash,\n \n users.email_confirmed,\n users.email_confirmed_by_google,\n users.email_is_synthetic,\n users.is_without_password,\n users.username_is_not_customized,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND user_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n "
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
}
|
||||
},
|
||||
"hash": "b22b2a775ddce30c04a7d89f34a606782a695b00e3c78d3891b6c0642d2c6c4c",
|
||||
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = 'device_approval'\n AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL\n AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
}
|
||||
},
|
||||
"hash": "d68616cd92f80feedf269e04c8d96087f0c44ee12b8cc2768793095e39fd5a35",
|
||||
"query": "\nUPDATE user_login_challenges SET status = 'failed', maybe_failure_type = 'expired', maybe_failed_at = expires_at\nWHERE status IN ('pending', 'approved') AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"hash": "d84ac4de934fc5f53e6f202e23329f6db3c29939dfec0bcd29612a8cd9e2f0ce",
|
||||
"query": "\nUPDATE user_login_challenges SET status = 'redeemed', maybe_redeemed_user_session_id = ?,\n maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()\nWHERE id = ? AND status = 'approved' AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "db26c830f0b7bb64f8e9bce62eee82f3844c62d8de32f9e76590c27a9c2b3b74",
|
||||
"query": "\nUPDATE user_login_challenges SET status = 'failed', maybe_failure_type = 'expired', maybe_failed_at = expires_at\nWHERE id = ? AND status IN ('pending', 'approved') AND expires_at <= NOW()\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"hash": "e82bcef4f8ac327c3f53be4beb0568f8bc1523f91a46a8d883560872c84d1e23",
|
||||
"query": "\nINSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)\nSELECT ?, token, ?, 'device_approval', NOW() + INTERVAL 1 YEAR FROM users\nWHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
}
|
||||
},
|
||||
"hash": "fb5ceb1ebb2d7f424c590836842948b19102cc583749621e338ccbb23a984b3d",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,\n maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,\n maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())\nWHERE id = ? AND status = 'pending' AND expires_at > NOW()\n"
|
||||
}
|
||||
Generated
+1
@@ -8775,6 +8775,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"sha1_hash",
|
||||
"sha2 0.11.0-rc.5",
|
||||
"shared_env_var_config",
|
||||
"speculoos",
|
||||
"sqlx",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_login_challenges;
|
||||
@@ -0,0 +1,92 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- One approval request for a new cookie-based user session on another device.
|
||||
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
|
||||
-- This table does not authorize MCP sessions or API keys.
|
||||
CREATE TABLE user_login_challenges (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
|
||||
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
|
||||
-- The approval token goes in the browser/QR URL and only permits reviewing a
|
||||
-- request and submitting an authenticated user's explicit approve/decline choice.
|
||||
approval_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Only the initiating device receives this token. Required for polling/redeeming.
|
||||
-- Never include it in the approval URL, QR code, browser page, or application logs.
|
||||
device_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
|
||||
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
|
||||
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
|
||||
-- Only pending may be approved/declined. Only approved may mint a session.
|
||||
-- Expiry is checked independently of status on EVERY approval/redemption request.
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- The authenticated web user who approved OR declined. NULL until a decision.
|
||||
-- On approval this becomes the immutable owner of the session to be minted.
|
||||
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Set atomically with status=redeemed and session insertion, under a row lock.
|
||||
-- Numeric reference avoids copying a session credential into this audit table.
|
||||
-- Intentionally signed to match the existing user_sessions.id column.
|
||||
-- At most one session per challenge; retries may only return that SAME live session.
|
||||
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
|
||||
|
||||
-- UserLoginChallengeFailureType: user_declined, expired.
|
||||
-- Only failed rows have a failure type and maybe_failed_at.
|
||||
-- Transient errors / invalid credentials must not overwrite a valid challenge.
|
||||
maybe_failure_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- Server-observed requesting device IP, displayed on the approval screen.
|
||||
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Browser/mobile IP for approval or denial; may differ from the device IP.
|
||||
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
|
||||
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
|
||||
-- ip_address_creation is retained on ALL failures, including timeouts.
|
||||
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
|
||||
-- The default deliberately fails closed if a writer forgets to set the deadline.
|
||||
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
|
||||
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- Decision time for user_declined; expires_at for expired (even if recorded later).
|
||||
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (approval_token_sha256),
|
||||
UNIQUE KEY (device_token_sha256),
|
||||
UNIQUE KEY (maybe_redeemed_user_session_id),
|
||||
KEY index_status_expires_at (status, expires_at),
|
||||
KEY index_expires_at (expires_at),
|
||||
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
|
||||
KEY index_creation_ip_created_at (ip_address_creation, created_at),
|
||||
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE user_sessions
|
||||
DROP COLUMN maybe_creation_type;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
|
||||
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
|
||||
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
|
||||
-- MCP sessions and API keys are separate tables and do not use this field.
|
||||
ALTER TABLE user_sessions
|
||||
ADD COLUMN maybe_creation_type VARCHAR(32) DEFAULT NULL;
|
||||
@@ -0,0 +1,92 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- One approval request for a new cookie-based user session on another device.
|
||||
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
|
||||
-- This table does not authorize MCP sessions or API keys.
|
||||
CREATE TABLE user_login_challenges (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
|
||||
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
|
||||
-- The approval token goes in the browser/QR URL and only permits reviewing a
|
||||
-- request and submitting an authenticated user's explicit approve/decline choice.
|
||||
approval_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Only the initiating device receives this token. Required for polling/redeeming.
|
||||
-- Never include it in the approval URL, QR code, browser page, or application logs.
|
||||
device_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
|
||||
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
|
||||
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
|
||||
-- Only pending may be approved/declined. Only approved may mint a session.
|
||||
-- Expiry is checked independently of status on EVERY approval/redemption request.
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
|
||||
-- The authenticated web user who approved OR declined. NULL until a decision.
|
||||
-- On approval this becomes the immutable owner of the session to be minted.
|
||||
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Set atomically with status=redeemed and session insertion, under a row lock.
|
||||
-- Numeric reference avoids copying a session credential into this audit table.
|
||||
-- Intentionally signed to match the existing user_sessions.id column.
|
||||
-- At most one session per challenge; retries may only return that SAME live session.
|
||||
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
|
||||
|
||||
-- UserLoginChallengeFailureType: user_declined, expired.
|
||||
-- Only failed rows have a failure type and maybe_failed_at.
|
||||
-- Transient errors / invalid credentials must not overwrite a valid challenge.
|
||||
maybe_failure_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- Server-observed requesting device IP, displayed on the approval screen.
|
||||
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Browser/mobile IP for approval or denial; may differ from the device IP.
|
||||
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
|
||||
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
|
||||
-- ip_address_creation is retained on ALL failures, including timeouts.
|
||||
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
|
||||
-- The default deliberately fails closed if a writer forgets to set the deadline.
|
||||
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
|
||||
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- Decision time for user_declined; expires_at for expired (even if recorded later).
|
||||
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (approval_token_sha256),
|
||||
UNIQUE KEY (device_token_sha256),
|
||||
UNIQUE KEY (maybe_redeemed_user_session_id),
|
||||
KEY index_status_expires_at (status, expires_at),
|
||||
KEY index_expires_at (expires_at),
|
||||
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
|
||||
KEY index_creation_ip_created_at (ip_address_creation, created_at),
|
||||
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -41,6 +41,12 @@ CREATE TABLE user_sessions (
|
||||
-- designed to be recoverable.
|
||||
deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
|
||||
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
|
||||
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
|
||||
-- MCP sessions and API keys are separate tables and do not use this field.
|
||||
maybe_creation_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# Website-approved device login
|
||||
|
||||
Implemented across storyteller-web, the browser approval page, and the Tauri
|
||||
login dialog. Apply the two migrations before deploying the API; deploy the web
|
||||
approval route before releasing the desktop buttons. No production migrations or
|
||||
deployments were performed while developing this change.
|
||||
|
||||
Call the flow **device approval**, and each attempt a **login challenge**. It works
|
||||
for a desktop browser link or a phone scanning a QR code, regardless of how the
|
||||
approving account signed in. It creates an ordinary cookie-based web session.
|
||||
|
||||
The design borrows separate device/browser credentials, polling, explicit consent,
|
||||
and code comparison from [OAuth device authorization (RFC 8628)](https://www.rfc-editor.org/rfc/rfc8628.html#section-3.3.1).
|
||||
It is an ArtCraft session bridge, not an OAuth token endpoint implementation.
|
||||
|
||||
## Two migrations
|
||||
|
||||
1. `2026-09-25-120000-0000_create_user_login_challenges` creates the challenge and
|
||||
its terminal outcome audit record together. A separate failure table is not
|
||||
needed for one immutable outcome per attempt.
|
||||
2. `2026-09-25-120000-0001_alter_user_sessions_add_maybe_creation_type` adds nullable
|
||||
provenance to cookie sessions. Existing rows and older writers retain NULL.
|
||||
|
||||
Both have reverse migrations and matching materialized schemas under
|
||||
`migrations_squashed/user_tables/`. Deploy the schema before new writers. Roll
|
||||
back feature code before dropping the column/table; rollback removes bridge audit
|
||||
records but does not revoke sessions already issued.
|
||||
|
||||
`UserWebSessionCreationType` values:
|
||||
|
||||
| Rust variant | Stored value | Meaning |
|
||||
|------------------|-------------------|-------------------------------------------------------------------|
|
||||
| DirectLogin | direct_login | Password or SSO login/signup, including desktop password login. |
|
||||
| DeviceApproval | device_approval | A new session minted from an approved login challenge. |
|
||||
| Impersonation | impersonation | Existing consented staff impersonation flow. |
|
||||
| None | NULL | Unknown provenance, including existing rows and older writers. |
|
||||
|
||||
`DirectLogin` describes the creation mechanism more accurately than `Web`, since
|
||||
ordinary password login is also available in the desktop app. This field does not
|
||||
replace `maybe_impersonation_user_token` or influence authentication/authorization.
|
||||
MCP and API credentials continue using `mcp_sessions` and `api_keys` respectively.
|
||||
Existing password/SSO and impersonation session writers now record provenance.
|
||||
There is no historical backfill or default that would invent it.
|
||||
|
||||
## Credentials and confirmation
|
||||
|
||||
Generate two independent 32-byte CSPRNG values per challenge. Encode each as
|
||||
unpadded base64url (43 ASCII characters). Store only the SHA-256 digest of that
|
||||
canonical ASCII representation in the two uniquely indexed `BINARY(32)` columns.
|
||||
Reject noncanonical input instead of accepting multiple representations. These
|
||||
are random tokens, not passwords, so a fast cryptographic hash is appropriate.
|
||||
|
||||
| Value | Who receives it | What it permits |
|
||||
|---------------------|-------------------------------|--------------------------------------------------------|
|
||||
| token | Internal logs/support | Identifying a row; no authentication. |
|
||||
| approval token | Desktop, browser link, QR | Review and authenticated approval/decline only. |
|
||||
| device token | Initiating desktop only | Polling and collecting the newly authorized session. |
|
||||
| confirmation code | Desktop and approval screen | Visual comparison only; no lookup or authentication. |
|
||||
|
||||
Use eight uniformly random characters from `BCDFGHJKLMNPQRSTVWXZ` for the code.
|
||||
Store `WDJBMJHT`, display `WDJB-MJHT`, and ask the user to verify the match. It need
|
||||
not be unique because it is never used to locate or authorize a challenge. Manual
|
||||
short-code entry is outside this initial flow; both entry points carry the full
|
||||
approval token. The private device token must never be in the URL or QR code.
|
||||
|
||||
The website displays the requesting IP, the account being authorized, the matching
|
||||
code, and explicit **Approve desktop login** / **Decline** actions. An existing
|
||||
website session skips only the login screen, never consent. If login is needed,
|
||||
keep the pending approval context in first-party browser state and resume the
|
||||
confirmation page after password or Google SSO login. Recheck expiry after login.
|
||||
Do not send the bridge tokens to Google or another identity provider. Allow only
|
||||
the fixed first-party return route, not an arbitrary caller-supplied redirect URL.
|
||||
|
||||
The approval page is a read-only GET; link scanners and previews cannot decide
|
||||
the challenge. Decisions require a valid, non-impersonated user web session plus
|
||||
CSRF/origin protection. Never accept an API key or MCP credential as consent.
|
||||
Require the user to have initiated the request on a device in their possession;
|
||||
the visible code and IP help detect phishing but are not authentication factors.
|
||||
|
||||
The approval URL uses HTTPS and carries the browser credential in its fragment.
|
||||
The page captures and removes that fragment before referral collection or React
|
||||
providers initialize, skips analytics/referral tracking on direct entry, and sets
|
||||
a no-referrer policy. API responses send `Cache-Control: no-store` and
|
||||
`Referrer-Policy: no-referrer`; API credentials travel in JSON bodies, never query
|
||||
strings. Shared fonts and the Google sign-in provider still load. Keep request and
|
||||
response bodies out of gateway logs. Invalid credentials reveal no account or
|
||||
challenge details. The IP follows the existing trusted-gateway forwarding policy;
|
||||
the gateway must replace untrusted incoming forwarding headers.
|
||||
|
||||
## State and the 20-minute deadline
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> approved: authenticated approval
|
||||
pending --> failed: user_declined
|
||||
pending --> failed: expired
|
||||
approved --> failed: expired before redemption
|
||||
approved --> redeemed: atomically mint one session
|
||||
redeemed --> [*]
|
||||
failed --> [*]
|
||||
```
|
||||
|
||||
Set `created_at = NOW()` and `expires_at = NOW() + INTERVAL 20 MINUTE` in the same
|
||||
INSERT using the database's clock. The default expiry is immediate so a writer
|
||||
that omits it fails closed. Neither approval nor polling extends this deadline.
|
||||
All eligibility checks require `expires_at > NOW()`; equality means expired.
|
||||
The create query enforces this exact lifetime; these migrations do not add
|
||||
triggers or database CHECK constraints. Keep database connections in UTC.
|
||||
|
||||
The persisted status alone is insufficient to decide validity. A pending or
|
||||
approved row whose deadline passed is already expired even before a sweeper
|
||||
marks it `failed`. Approvals and first redemptions must check the deadline again
|
||||
under the row lock. Polls are normally read-only and do not touch `updated_at`.
|
||||
|
||||
| Status | Required accompanying data |
|
||||
|------------|-------------------------------------------------------------------------------------|
|
||||
| pending | No decision, failure, redemption, or session reference. |
|
||||
| approved | Deciding user, decision IP/time; no failure, redemption, or session reference. |
|
||||
| redeemed | Approval data plus session ID, first redemption IP/time; no failure. |
|
||||
| failed | Failure type/time; no redemption or session reference; retain any prior approval. |
|
||||
|
||||
`UserLoginChallengeFailureType` initially has two values:
|
||||
|
||||
- `user_declined`: record the deciding user, browser IP in both decision/failure
|
||||
IP fields, and the decision time in both decision/failure timestamps. Keep the
|
||||
original desktop IP. Polling with the valid device token reports the decline.
|
||||
- `expired`: record `maybe_failed_at = expires_at`. Retain original desktop IP and
|
||||
any prior approval, but leave the failure IP NULL: a timeout has no actor. A
|
||||
scheduled sweep records abandoned requests even when nobody polls again.
|
||||
|
||||
Failed challenges are unusable for approval or redemption. Their digests and
|
||||
audit metadata remain for diagnosing failures; do not physically delete the row
|
||||
when a user declines. A new attempt creates a new row and fresh tokens. Closing
|
||||
the desktop dialog stops polling; unused requests expire normally.
|
||||
|
||||
Invalid tokens, failed website sign-ins, throttling, and transient infrastructure
|
||||
errors do not terminally fail a valid challenge. Record those requests in the
|
||||
existing operational/abuse logging with their IP and failure category, without
|
||||
credentials. A second decision cannot replace the first actor or outcome.
|
||||
|
||||
## Atomic redemption and response loss
|
||||
|
||||
The website's approval records consent; it never creates or receives the desktop
|
||||
cookie. The desktop's authenticated poll performs redemption after approval:
|
||||
|
||||
1. Begin an InnoDB transaction on the primary and find the challenge by the device
|
||||
token digest with `SELECT ... FOR UPDATE`.
|
||||
2. Validate the immutable deadline and state after taking the lock. Handle
|
||||
failed/pending states without creating a session. Revalidate that the approved
|
||||
user exists and is permitted to log in under the ordinary session rules.
|
||||
3. For `approved`, insert a fresh `user_sessions` row owned by the deciding user,
|
||||
with `maybe_creation_type = 'device_approval'`, no impersonation, the current
|
||||
desktop redemption IP, and the ordinary web-session lifetime.
|
||||
4. Store its numeric ID, redemption IP/time, and `status = 'redeemed'` on the
|
||||
challenge in that same transaction. Generate the signed cookie using
|
||||
`HttpUserSessionManager::create_cookie` before commit, so signing failure rolls
|
||||
back too. Commit successfully before returning any credential to the caller.
|
||||
|
||||
Both approval/decline and redemption must serialize on the same challenge row;
|
||||
conditional updates include the expected state and the unexpired deadline, and
|
||||
must check rows affected. SQL for this flow belongs in `mysql_queries`, with
|
||||
executor/transaction support, not in the web handler. All three Rust enums in this
|
||||
change are database enums; separate client-facing status/failure enums include
|
||||
an unknown-value fallback. Desktop callers fail closed on an unknown state.
|
||||
|
||||
One-time means **at most one new session per challenge**. If the first successful
|
||||
HTTP response is lost, a retry with the same device token may return the same
|
||||
session until the original 20-minute deadline. For `redeemed`, read the linked
|
||||
session and verify its owner, creation type, absence of impersonation, expiry,
|
||||
deletion state, and the user's current eligibility before reconstructing its
|
||||
cookie. Never mint a replacement or resurrect a deleted session. After the
|
||||
deadline, return expiry without a cookie; the already-issued session keeps its
|
||||
normal lifetime, and the audit outcome remains `redeemed`.
|
||||
|
||||
The unique nullable session ID index prevents two challenges from referencing one
|
||||
session and supports retries without duplicating bearer credentials in this
|
||||
table. The reference is intentionally signed because the legacy `user_sessions.id`
|
||||
is signed. Like neighboring schemas, references are indexed logical links rather
|
||||
than cascading foreign keys; removal of a session must not erase the audit row.
|
||||
If the referenced session is missing, redemption fails closed.
|
||||
|
||||
## Implemented endpoints and clients
|
||||
|
||||
All endpoints are JSON POSTs under `/v1/login_challenges`:
|
||||
|
||||
| Endpoint | Authentication | Effect |
|
||||
|------------|---------------------------------------------|--------------------------------------------------------------|
|
||||
| create | None; limited by requesting IP | Returns device token, verification URL, code, expiry. |
|
||||
| review | Approval token + web session + Origin | Returns code, requesting IP, account, state, expiry. |
|
||||
| decide | Approval token + web session + Origin | Records the first Yes/No; never creates a desktop session. |
|
||||
| poll | Private device token | Reports state or atomically redeems approved consent. |
|
||||
|
||||
The API advertises five-second polling. Per-process IP limits are ten creates and
|
||||
120 other requests per minute, shared across Actix workers, with bounded memory.
|
||||
Production gateways can additionally enforce aggregate limits across replicas.
|
||||
A worker expires up to 1,000 abandoned challenges every 30 seconds. Audit retention
|
||||
is intentionally not an automatic purge policy in this change.
|
||||
|
||||
The website route is `https://app.getartcraft.com/login/desktop`. The token is kept
|
||||
in tab-scoped session storage through login, including Google login for accounts
|
||||
without a subscription. Returning to the page still requires explicit consent.
|
||||
The desktop displays the QR code, code comparison, countdown, and status. Opening
|
||||
the website invokes the native system browser. Network errors back off up to 30
|
||||
seconds; closing the dialog stops polling and ignores late UI results. An already
|
||||
in-flight HTTP response may still update the native HTTP cookie jar.
|
||||
|
||||
Successful polling returns the signed value and the normal `Set-Cookie` header.
|
||||
The Tauri command synchronizes the HTTP cookie jar with native credentials, scoped
|
||||
to the configured API origin, and the UI verifies the ordinary session endpoint
|
||||
before reporting success. No browser session is copied into the desktop.
|
||||
|
||||
## Integration tests
|
||||
|
||||
The backend harness uses the fixed local Unix socket `/tmp/mysql.sock` and local
|
||||
root account. It never reads `DATABASE_URL`, any environment database URL, DNS,
|
||||
or a TCP endpoint (including localhost SSH forwarding). Each test creates a new
|
||||
randomly named `artcraft_test_bridge_*` database, replays all migrations, and drops
|
||||
only its own database on success. Existing `storyteller` and `artcraft_test` data
|
||||
are not used. A failed test leaves its isolated database for diagnosis. The local
|
||||
MySQL instance must be running; there is no fallback to a remote database.
|
||||
|
||||
From the services repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
|
||||
cd frontend
|
||||
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
|
||||
```
|
||||
|
||||
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
|
||||
real SQL against local MySQL. Do not enable `skip_database_tests` for this run.
|
||||
|
||||
From the desktop repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
|
||||
cd frontend
|
||||
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx
|
||||
```
|
||||
|
||||
Coverage:
|
||||
|
||||
- Six backend integration tests: passwordless account approval, zero sessions
|
||||
before redemption, rejection/audit IPs, token separation, CSRF, MCP rejection,
|
||||
invalid approving sessions, concurrent decisions/redemptions, response-loss
|
||||
retries, abandoned expiry, expiry during lock contention, revocation, bans,
|
||||
signed-cookie authentication through the production session guard, and an issued
|
||||
session remaining valid after the challenge expires.
|
||||
- Eight browser tests: API/consent interaction, code/IP display, decline, expiry,
|
||||
invalid requests, safe return URLs, login continuation, and Google sign-in
|
||||
continuation without subscription gating or bridge credentials in SSO requests.
|
||||
- Seven desktop UI tests: native browser invocation, QR/code values, polling,
|
||||
successful credential synchronization and downstream session verification,
|
||||
decline, timeout, network backoff, unmount, and unknown-state handling.
|
||||
- Three native tests: HTTP response cookie to native credentials, retry safety,
|
||||
exclusion of unrelated/expired cookies, and local/production origin isolation.
|
||||
|
||||
Frontend tests replace external HTTP and Google/native boundaries; they do not
|
||||
perform a live Google OAuth exchange or drive a packaged Tauri GUI. Backend tests
|
||||
use real MySQL and the production cookie signer/session guard with Redis disabled.
|
||||
Existing session-cache TTL behavior is unchanged. SQL lookups now reject expired
|
||||
sessions; bridge consent/redemption always checks MySQL directly.
|
||||
|
||||
Validation also covered both migration directions, nullable compatibility with
|
||||
legacy session writers, uniqueness constraints, fail-closed expiry defaults, and
|
||||
matching materialized schemas. The focused web TypeScript check and the production
|
||||
webapp Nx build (including 52 dependency tasks) passed. Broad workspace TypeScript
|
||||
checks have existing errors. The desktop Nx build is blocked by the existing
|
||||
`api -> tauri-api -> api` cycle; its direct Vite login bundle builds but declaration
|
||||
checks report unavailable dependency outputs. A source-only desktop check reports
|
||||
four existing `model-list` imports of `src/index.js`, with no bridge-file errors.
|
||||
The full native Rust test target compiled successfully.
|
||||
@@ -0,0 +1,73 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct CreateLoginChallengeRequest {}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct ReviewLoginChallengeRequest {
|
||||
pub approval_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct DecideLoginChallengeRequest {
|
||||
pub approval_token: String,
|
||||
pub approve: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct PollLoginChallengeRequest {
|
||||
pub device_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct CreateLoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub device_token: String,
|
||||
pub verification_url: String,
|
||||
pub confirmation_code: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub poll_interval_seconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct ReviewLoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub status: LoginChallengeState,
|
||||
pub maybe_failure_type: Option<LoginChallengeFailure>,
|
||||
pub confirmation_code: String,
|
||||
pub requesting_ip: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct LoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub status: LoginChallengeState,
|
||||
pub maybe_failure_type: Option<LoginChallengeFailure>,
|
||||
/// Present only to the device after the redemption transaction commits.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_signed_session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginChallengeState {
|
||||
Pending,
|
||||
Approved,
|
||||
Redeemed,
|
||||
Failed,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginChallengeFailure {
|
||||
UserDeclined,
|
||||
Expired,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod change_password;
|
||||
pub mod edit_email;
|
||||
pub mod edit_username;
|
||||
pub mod login;
|
||||
pub mod session_info;
|
||||
pub mod login_challenges;
|
||||
pub mod session_info;
|
||||
@@ -41,6 +41,7 @@ pub mod unsubscribe_reason;
|
||||
pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_referrals;
|
||||
pub mod user_daily_spends;
|
||||
@@ -52,4 +53,4 @@ pub mod voice_conversion;
|
||||
pub mod voice_designer;
|
||||
pub mod wallet_ledger_entries;
|
||||
pub mod wallets;
|
||||
pub mod web_referrals;
|
||||
pub mod web_referrals;
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{MySqlConnection, MySqlPool};
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
|
||||
pub struct LoginChallenge {
|
||||
pub id: u64,
|
||||
pub confirmation_code: String,
|
||||
pub status: String,
|
||||
pub maybe_deciding_user_token: Option<String>,
|
||||
pub maybe_redeemed_user_session_id: Option<i64>,
|
||||
pub maybe_failure_type: Option<String>,
|
||||
pub ip_address_creation: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct CreateChallengeArgs<'a> {
|
||||
pub token: &'a UserLoginChallengeToken,
|
||||
pub approval_hash: &'a [u8],
|
||||
pub device_hash: &'a [u8],
|
||||
pub confirmation_code: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
}
|
||||
|
||||
pub async fn create_challenge(args: CreateChallengeArgs<'_>, conn: &mut MySqlConnection) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_login_challenges
|
||||
(token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
args.approval_hash,
|
||||
args.device_hash,
|
||||
args.confirmation_code,
|
||||
args.ip_address
|
||||
)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call inside a transaction. The approval token cannot be used in device lookups.
|
||||
pub async fn lock_by_approval(hash: &[u8], conn: &mut MySqlConnection) -> Result<Option<LoginChallenge>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
LoginChallenge,
|
||||
r#"
|
||||
SELECT id, confirmation_code, status, maybe_deciding_user_token, maybe_redeemed_user_session_id,
|
||||
maybe_failure_type, ip_address_creation, expires_at
|
||||
FROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE
|
||||
"#,
|
||||
hash
|
||||
)
|
||||
.fetch_optional(conn)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lock_by_device(hash: &[u8], conn: &mut MySqlConnection) -> Result<Option<LoginChallenge>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
LoginChallenge,
|
||||
r#"
|
||||
SELECT id, confirmation_code, status, maybe_deciding_user_token, maybe_redeemed_user_session_id,
|
||||
maybe_failure_type, ip_address_creation, expires_at
|
||||
FROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE
|
||||
"#,
|
||||
hash
|
||||
)
|
||||
.fetch_optional(conn)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run AFTER acquiring the row lock: NOW() on the locking statement can precede
|
||||
/// time spent waiting for another transaction. Never extend the original deadline.
|
||||
pub async fn expire_locked_challenge(challenge: &mut LoginChallenge, conn: &mut MySqlConnection) -> Result<bool, sqlx::Error> {
|
||||
let expired = sqlx::query!(
|
||||
r#"
|
||||
SELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?
|
||||
"#,
|
||||
challenge.id
|
||||
)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?
|
||||
.expired
|
||||
!= 0;
|
||||
if expired && (challenge.status == "pending" || challenge.status == "approved") {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = 'failed', maybe_failure_type = 'expired', maybe_failed_at = expires_at
|
||||
WHERE id = ? AND status IN ('pending', 'approved') AND expires_at <= NOW()
|
||||
"#,
|
||||
challenge.id
|
||||
)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
challenge.status = "failed".into();
|
||||
challenge.maybe_failure_type = Some("expired".into());
|
||||
}
|
||||
Ok(expired)
|
||||
}
|
||||
|
||||
pub async fn decide_challenge(id: u64, user: &str, ip: &str, approve: bool, conn: &mut MySqlConnection) -> Result<bool, sqlx::Error> {
|
||||
let status = if approve { "approved" } else { "failed" };
|
||||
let failure = if approve { None } else { Some("user_declined") };
|
||||
let failure_ip = if approve { None } else { Some(ip) };
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,
|
||||
maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,
|
||||
maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())
|
||||
WHERE id = ? AND status = 'pending' AND expires_at > NOW()
|
||||
"#,
|
||||
status,
|
||||
user,
|
||||
ip,
|
||||
failure,
|
||||
failure_ip,
|
||||
approve,
|
||||
id
|
||||
)
|
||||
.execute(conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(affected == 1)
|
||||
}
|
||||
|
||||
pub async fn mark_redeemed(id: u64, session_id: i64, ip: &str, conn: &mut MySqlConnection) -> Result<bool, sqlx::Error> {
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = 'redeemed', maybe_redeemed_user_session_id = ?,
|
||||
maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()
|
||||
WHERE id = ? AND status = 'approved' AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL
|
||||
"#,
|
||||
session_id,
|
||||
ip,
|
||||
id
|
||||
)
|
||||
.execute(conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(affected == 1)
|
||||
}
|
||||
|
||||
/// Bounded sweep also audits abandoned attempts that are never polled again.
|
||||
pub async fn expire_abandoned_challenges(pool: &MySqlPool) -> Result<u64, sqlx::Error> {
|
||||
Ok(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = 'failed', maybe_failure_type = 'expired', maybe_failed_at = expires_at
|
||||
WHERE status IN ('pending', 'approved') AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000
|
||||
"#
|
||||
)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod challenge_queries;
|
||||
pub mod session_queries;
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
use sqlx::MySqlConnection;
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
|
||||
pub struct BridgeSession {
|
||||
pub id: i64,
|
||||
pub token: String,
|
||||
pub user_token: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Fresh database lookup, not a cached session. Impersonated sessions cannot consent.
|
||||
pub async fn find_approving_session(token: &str, conn: &mut MySqlConnection) -> Result<Option<BridgeSession>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
BridgeSession,
|
||||
r#"
|
||||
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
|
||||
WHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()
|
||||
AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE
|
||||
AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
|
||||
"#,
|
||||
token
|
||||
)
|
||||
.fetch_optional(conn)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns the one previously minted session, only while it and its owner are live.
|
||||
pub async fn find_redeemed_session(id: i64, user: &str, conn: &mut MySqlConnection) -> Result<Option<BridgeSession>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
BridgeSession,
|
||||
r#"
|
||||
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
|
||||
WHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = 'device_approval'
|
||||
AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL
|
||||
AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
|
||||
"#,
|
||||
id,
|
||||
user
|
||||
)
|
||||
.fetch_optional(conn)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Must share the transaction holding the challenge lock. A failed/expired
|
||||
/// redemption rolls back this INSERT; no session is minted when merely approving.
|
||||
pub async fn create_bridge_session(user: &str, ip: &str, conn: &mut MySqlConnection) -> Result<Option<BridgeSession>, sqlx::Error> {
|
||||
let token = UserSessionToken::generate();
|
||||
let inserted = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)
|
||||
SELECT ?, token, ?, 'device_approval', NOW() + INTERVAL 1 YEAR FROM users
|
||||
WHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL
|
||||
"#,
|
||||
token.as_str(),
|
||||
ip,
|
||||
user
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
if inserted.rows_affected() != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
find_redeemed_session(inserted.last_insert_id() as i64, user, conn).await
|
||||
}
|
||||
+2
-1
@@ -36,9 +36,10 @@ INSERT INTO user_sessions (
|
||||
user_token,
|
||||
maybe_impersonation_user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 'impersonation', ?)
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
args.user_token.as_str(),
|
||||
|
||||
+2
-1
@@ -17,9 +17,10 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token,
|
||||
user_token.to_string(),
|
||||
|
||||
+2
-1
@@ -19,9 +19,10 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
user_token.as_str(),
|
||||
|
||||
+2
-1
@@ -22,9 +22,10 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
user_token.as_str(),
|
||||
|
||||
+1
@@ -169,6 +169,7 @@ LEFT OUTER JOIN user_roles
|
||||
ON users.user_role_slug = user_roles.slug
|
||||
WHERE user_sessions.token = ?
|
||||
AND user_sessions.deleted_at IS NULL
|
||||
AND user_sessions.expires_at > NOW()
|
||||
AND users.user_deleted_at IS NULL
|
||||
AND users.mod_deleted_at IS NULL
|
||||
"#,
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ SELECT
|
||||
FROM user_sessions
|
||||
WHERE token = ?
|
||||
AND deleted_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
"#,
|
||||
session_token,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Mutators/inspection for disposable bridge integration databases only.
|
||||
use sqlx::{MySqlPool, Row};
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
pub struct ChallengeAudit {
|
||||
pub status: String,
|
||||
pub failure: Option<String>,
|
||||
pub creation_ip: String,
|
||||
pub failure_ip: Option<String>,
|
||||
pub session_id: Option<i64>,
|
||||
pub lifetime_seconds: i64,
|
||||
}
|
||||
|
||||
pub async fn audit(pool: &MySqlPool, device_hash: &[u8]) -> ChallengeAudit {
|
||||
let row = sqlx::query("SELECT status, maybe_failure_type, ip_address_creation, maybe_ip_address_failure, maybe_redeemed_user_session_id, TIMESTAMPDIFF(SECOND, created_at, expires_at) AS lifetime FROM user_login_challenges WHERE device_token_sha256 = ?").bind(device_hash).fetch_one(pool).await.expect("challenge audit");
|
||||
ChallengeAudit { status: row.get("status"), failure: row.get("maybe_failure_type"), creation_ip: row.get("ip_address_creation"), failure_ip: row.get("maybe_ip_address_failure"), session_id: row.get("maybe_redeemed_user_session_id"), lifetime_seconds: row.get("lifetime") }
|
||||
}
|
||||
|
||||
pub async fn bridge_session_count(pool: &MySqlPool) -> i64 {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE maybe_creation_type = 'device_approval'").fetch_one(pool).await.expect("bridge session count")
|
||||
}
|
||||
|
||||
pub async fn set_challenge_deadline(pool: &MySqlPool, device_hash: &[u8], seconds_from_now: i32) {
|
||||
sqlx::query("UPDATE user_login_challenges SET expires_at = TIMESTAMPADD(SECOND, ?, NOW()) WHERE device_token_sha256 = ?").bind(seconds_from_now).bind(device_hash).execute(pool).await.expect("change test deadline");
|
||||
}
|
||||
|
||||
pub async fn expire_session(pool: &MySqlPool, token: &str) {
|
||||
sqlx::query("UPDATE user_sessions SET expires_at = NOW() - INTERVAL 1 SECOND WHERE token = ?").bind(token).execute(pool).await.expect("expire test session");
|
||||
}
|
||||
|
||||
pub async fn revoke_session(pool: &MySqlPool, token: &str) {
|
||||
sqlx::query("UPDATE user_sessions SET deleted_at = NOW() WHERE token = ?").bind(token).execute(pool).await.expect("revoke test session");
|
||||
}
|
||||
|
||||
pub async fn set_passwordless(pool: &MySqlPool, user: &UserToken) {
|
||||
sqlx::query("UPDATE users SET is_without_password = TRUE, email_confirmed_by_google = TRUE WHERE token = ?").bind(user.as_str()).execute(pool).await.expect("passwordless test account");
|
||||
}
|
||||
|
||||
pub async fn ban_user(pool: &MySqlPool, user: &UserToken) {
|
||||
sqlx::query("UPDATE users SET is_banned = TRUE WHERE token = ?").bind(user.as_str()).execute(pool).await.expect("ban test account");
|
||||
}
|
||||
|
||||
pub async fn impersonate_session(pool: &MySqlPool, token: &str, actor: &UserToken) {
|
||||
sqlx::query("UPDATE user_sessions SET maybe_impersonation_user_token = ? WHERE token = ?").bind(actor.as_str()).bind(token).execute(pool).await.expect("test impersonation");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod mcp_sessions;
|
||||
pub mod login_challenges;
|
||||
pub mod media_files;
|
||||
pub mod users;
|
||||
pub mod wallets;
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Disposable databases on the local MySQL instance only. No application or test
|
||||
//! database URL is read, so production configuration cannot redirect these tests.
|
||||
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
use sqlx::MySqlPool;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::schema::ensure_schema;
|
||||
|
||||
pub struct IsolatedTestDatabase {
|
||||
pub pool: MySqlPool,
|
||||
admin_pool: MySqlPool,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl IsolatedTestDatabase {
|
||||
pub async fn create() -> Self {
|
||||
// Deliberately no URL/env parsing, DNS, TCP (including forwarded local ports),
|
||||
// or socket overrides. This is the local Homebrew MySQL socket. Only a new
|
||||
// randomly named database can be created/dropped; existing schemas are unused.
|
||||
let options = MySqlConnectOptions::new().socket("/tmp/mysql.sock").username("root").ssl_mode(MySqlSslMode::Disabled);
|
||||
let admin_pool = MySqlPoolOptions::new().max_connections(1).connect_with(options.clone()).await.expect("local MySQL test connection");
|
||||
let name = format!("artcraft_test_bridge_{}", UserToken::generate().as_str());
|
||||
assert!(name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
|
||||
sqlx::query(&format!("CREATE DATABASE `{name}`")).execute(&admin_pool).await.expect("create disposable database");
|
||||
let pool = MySqlPoolOptions::new().max_connections(8).connect_with(options.database(&name)).await.expect("connect disposable database");
|
||||
ensure_schema(&pool).await;
|
||||
Self { pool, admin_pool, name }
|
||||
}
|
||||
|
||||
pub async fn destroy(self) {
|
||||
self.pool.close().await;
|
||||
sqlx::query(&format!("DROP DATABASE `{}`", self.name)).execute(&self.admin_pool).await.expect("drop our disposable database");
|
||||
self.admin_pool.close().await;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
pub mod fixtures;
|
||||
pub mod guard;
|
||||
pub mod isolated;
|
||||
pub mod pool;
|
||||
pub mod schema;
|
||||
pub mod serial;
|
||||
@@ -24,7 +24,9 @@ pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod usages;
|
||||
pub mod user_bookmarks;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_ratings;
|
||||
pub mod user_sessions;
|
||||
pub mod user_spend_events;
|
||||
pub mod users;
|
||||
pub mod voice_conversion_models;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod user_login_challenge_failure_type;
|
||||
pub mod user_login_challenge_status;
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_login_challenges.maybe_failure_type` (VARCHAR(32)).
|
||||
///
|
||||
/// Terminal outcome for a failed device approval request. Transient transport/database errors and invalid credentials are not challenge outcomes.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserLoginChallengeFailureType {
|
||||
/// An authenticated user explicitly declined the request.
|
||||
#[serde(rename = "user_declined")]
|
||||
UserDeclined,
|
||||
|
||||
/// The 20-minute deadline passed before the challenge was redeemed.
|
||||
#[serde(rename = "expired")]
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserLoginChallengeFailureType);
|
||||
impl_mysql_enum_coders!(UserLoginChallengeFailureType);
|
||||
impl_mysql_from_row!(UserLoginChallengeFailureType);
|
||||
|
||||
impl UserLoginChallengeFailureType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UserDeclined => "user_declined",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"user_declined" => Ok(Self::UserDeclined),
|
||||
"expired" => Ok(Self::Expired),
|
||||
_ => Err(format!("invalid UserLoginChallengeFailureType value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::UserDeclined, Self::Expired])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserLoginChallengeFailureType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserLoginChallengeFailureType, &str)] = &[(UserLoginChallengeFailureType::UserDeclined, "user_declined"), (UserLoginChallengeFailureType::Expired, "expired")];
|
||||
const MAX_STORED_LENGTH: usize = 32;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserLoginChallengeFailureType::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserLoginChallengeFailureType::all_variants();
|
||||
assert_eq!(variants.len(), UserLoginChallengeFailureType::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserLoginChallengeFailureType::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserLoginChallengeFailureType::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "USER_DECLINED"] {
|
||||
assert!(UserLoginChallengeFailureType::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserLoginChallengeFailureType>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_login_challenges.status` (VARCHAR(16)).
|
||||
///
|
||||
/// Persisted state of a device approval request. Always check expires_at independently; a pending or approved row can already be expired.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserLoginChallengeStatus {
|
||||
/// Waiting for an authenticated user's explicit decision.
|
||||
#[serde(rename = "pending")]
|
||||
Pending,
|
||||
|
||||
/// Approved for the deciding user; no session has been minted yet.
|
||||
#[serde(rename = "approved")]
|
||||
Approved,
|
||||
|
||||
/// Exactly one session has been minted. Retries must never mint another.
|
||||
#[serde(rename = "redeemed")]
|
||||
Redeemed,
|
||||
|
||||
/// Terminal failure, described by maybe_failure_type.
|
||||
#[serde(rename = "failed")]
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserLoginChallengeStatus);
|
||||
impl_mysql_enum_coders!(UserLoginChallengeStatus);
|
||||
impl_mysql_from_row!(UserLoginChallengeStatus);
|
||||
|
||||
impl UserLoginChallengeStatus {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Approved => "approved",
|
||||
Self::Redeemed => "redeemed",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"approved" => Ok(Self::Approved),
|
||||
"redeemed" => Ok(Self::Redeemed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
_ => Err(format!("invalid UserLoginChallengeStatus value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::Pending, Self::Approved, Self::Redeemed, Self::Failed])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserLoginChallengeStatus;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserLoginChallengeStatus, &str)] = &[(UserLoginChallengeStatus::Pending, "pending"), (UserLoginChallengeStatus::Approved, "approved"), (UserLoginChallengeStatus::Redeemed, "redeemed"), (UserLoginChallengeStatus::Failed, "failed")];
|
||||
const MAX_STORED_LENGTH: usize = 16;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserLoginChallengeStatus::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserLoginChallengeStatus::all_variants();
|
||||
assert_eq!(variants.len(), UserLoginChallengeStatus::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserLoginChallengeStatus::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserLoginChallengeStatus::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "PENDING"] {
|
||||
assert!(UserLoginChallengeStatus::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserLoginChallengeStatus>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod user_web_session_creation_type;
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_sessions.maybe_creation_type` (VARCHAR(32)).
|
||||
///
|
||||
/// How a cookie-based user session was created; NULL in MySQL means unknown. This is provenance, not an authentication policy or an MCP/API session type.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserWebSessionCreationType {
|
||||
/// A normal password or SSO login/signup, including direct desktop password login.
|
||||
#[serde(rename = "direct_login")]
|
||||
DirectLogin,
|
||||
|
||||
/// A new session authorized through a user_login_challenges device approval.
|
||||
#[serde(rename = "device_approval")]
|
||||
DeviceApproval,
|
||||
|
||||
/// An explicitly consented staff impersonation; existing impersonation rules still apply.
|
||||
#[serde(rename = "impersonation")]
|
||||
Impersonation,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserWebSessionCreationType);
|
||||
impl_mysql_enum_coders!(UserWebSessionCreationType);
|
||||
impl_mysql_from_row!(UserWebSessionCreationType);
|
||||
|
||||
impl UserWebSessionCreationType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DirectLogin => "direct_login",
|
||||
Self::DeviceApproval => "device_approval",
|
||||
Self::Impersonation => "impersonation",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"direct_login" => Ok(Self::DirectLogin),
|
||||
"device_approval" => Ok(Self::DeviceApproval),
|
||||
"impersonation" => Ok(Self::Impersonation),
|
||||
_ => Err(format!("invalid UserWebSessionCreationType value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::DirectLogin, Self::DeviceApproval, Self::Impersonation])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserWebSessionCreationType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserWebSessionCreationType, &str)] = &[(UserWebSessionCreationType::DirectLogin, "direct_login"), (UserWebSessionCreationType::DeviceApproval, "device_approval"), (UserWebSessionCreationType::Impersonation, "impersonation")];
|
||||
const MAX_STORED_LENGTH: usize = 32;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserWebSessionCreationType::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserWebSessionCreationType::all_variants();
|
||||
assert_eq!(variants.len(), UserWebSessionCreationType::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserWebSessionCreationType::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserWebSessionCreationType::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "DIRECT_LOGIN"] {
|
||||
assert!(UserWebSessionCreationType::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserWebSessionCreationType>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ pub(crate) enum TokenPrefix {
|
||||
UserBookmark,
|
||||
UserReferralCode,
|
||||
UserSession,
|
||||
UserLoginChallenge,
|
||||
UserSpendEvent,
|
||||
UserSubscription,
|
||||
VoiceConversionModel,
|
||||
@@ -145,6 +146,7 @@ impl PrefixGenerator for TokenPrefix {
|
||||
Self::UserBookmark => "ub_",
|
||||
Self::UserReferralCode => "urc_",
|
||||
Self::UserSession => "session_",
|
||||
Self::UserLoginChallenge => "login_",
|
||||
Self::UserSpendEvent => "spend_",
|
||||
Self::UserSubscription => "subscription_", // NB: Previously "SUB:"
|
||||
Self::VoiceConversionModel => "vcm_",
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod user_bookmarks;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_sessions;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_spend_events;
|
||||
pub mod user_subscriptions;
|
||||
pub mod users;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// Non-secret identifier for a login challenge. This never grants access.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct UserLoginChallengeToken(pub String);
|
||||
|
||||
impl_string_token!(UserLoginChallengeToken);
|
||||
impl_mysql_token_from_row!(UserLoginChallengeToken);
|
||||
impl_crockford_generator!(UserLoginChallengeToken, 32usize, TokenPrefix::UserLoginChallenge, CrockfordLower);
|
||||
@@ -114,6 +114,7 @@ lexical-sort = "0.3.1"
|
||||
log.workspace = true
|
||||
once_cell.workspace = true
|
||||
rand.workspace = true
|
||||
sha2.workspace = true
|
||||
regex.workspace = true
|
||||
strum_macros = "0.26.4"
|
||||
time.workspace = true
|
||||
|
||||
@@ -274,6 +274,7 @@ use crate::http_server::endpoints::users::edit_username_handler::*;
|
||||
use crate::http_server::endpoints::users::get_profile_handler::*;
|
||||
use crate::http_server::endpoints::users::google_sso::google_sso_handler::*;
|
||||
use artcraft_api_defs::users::login::{LoginRequest, LoginSuccessResponse, LoginErrorType};
|
||||
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse, DecideLoginChallengeRequest, LoginChallengeFailure, LoginChallengeResponse, LoginChallengeState, PollLoginChallengeRequest, ReviewLoginChallengeRequest, ReviewLoginChallengeResponse};
|
||||
use crate::http_server::endpoints::users::login_handler::LoginErrorResponse;
|
||||
use crate::http_server::endpoints::users::logout_handler::*;
|
||||
use crate::http_server::endpoints::users::session_info_handler::*;
|
||||
@@ -570,6 +571,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::users::get_profile_handler::get_profile_handler,
|
||||
crate::http_server::endpoints::users::google_sso::google_sso_handler::google_sso_handler,
|
||||
crate::http_server::endpoints::users::login_handler::login_handler,
|
||||
crate::http_server::endpoints::login_challenges::handlers::create,
|
||||
crate::http_server::endpoints::login_challenges::handlers::review,
|
||||
crate::http_server::endpoints::login_challenges::handlers::decide,
|
||||
crate::http_server::endpoints::login_challenges::handlers::poll,
|
||||
crate::http_server::endpoints::users::logout_handler::logout_handler,
|
||||
crate::http_server::endpoints::users::session_info_handler::session_info_handler,
|
||||
crate::http_server::endpoints::users::session_token_info_handler::session_token_info_handler,
|
||||
@@ -917,9 +922,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
CreateCommentSuccessResponse,
|
||||
CreateFeaturedItemRequest,
|
||||
CreateFeaturedItemSuccessResponse,
|
||||
CreateLoginChallengeRequest,
|
||||
CreateLoginChallengeResponse,
|
||||
CreateUserBookmarkError,
|
||||
CreateUserBookmarkRequest,
|
||||
CreateUserBookmarkSuccessResponse,
|
||||
DecideLoginChallengeRequest,
|
||||
DeleteCommentError,
|
||||
DeleteCommentPathInfo,
|
||||
DeleteCommentRequest,
|
||||
@@ -1200,6 +1208,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
LogBrowserSessionError,
|
||||
LogBrowserSessionRequest,
|
||||
LogBrowserSessionSuccessResponse,
|
||||
LoginChallengeFailure,
|
||||
LoginChallengeResponse,
|
||||
LoginChallengeState,
|
||||
LoginErrorResponse,
|
||||
LoginErrorType,
|
||||
LoginRequest,
|
||||
@@ -1226,6 +1237,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
NewProjectMultipartForm,
|
||||
PinnedMediaFile,
|
||||
PinnedModelWeightForList,
|
||||
PollLoginChallengeRequest,
|
||||
ProjectMediaFileInfo,
|
||||
SessionMediaFileInfo,
|
||||
PromptInfo,
|
||||
@@ -1237,6 +1249,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
RenameMediaFileRequest,
|
||||
RequestDetailsResponse,
|
||||
ResultDetailsResponse,
|
||||
ReviewLoginChallengeRequest,
|
||||
ReviewLoginChallengeResponse,
|
||||
StaffAuditLogResponse,
|
||||
SearchFeaturedMediaFileListItem,
|
||||
SearchFeaturedMediaFilesQueryParams,
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_web::middleware::DefaultHeaders;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use artcraft_api_defs::users::login_challenges::*;
|
||||
use mysql_queries::queries::user_login_challenges::challenge_queries::{create_challenge, decide_challenge, expire_locked_challenge, lock_by_approval, lock_by_device, mark_redeemed, CreateChallengeArgs, LoginChallenge};
|
||||
use mysql_queries::queries::user_login_challenges::session_queries::{create_bridge_session, find_approving_session, find_redeemed_session, BridgeSession};
|
||||
use sqlx::{MySqlConnection, MySqlPool};
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use super::security::{new_confirmation_code, new_secret, rate_limit, request_ip, require_approval_origin, secret_hash};
|
||||
|
||||
const VERIFICATION_PAGE: &str = "https://app.getartcraft.com/login/desktop";
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/v1/login_challenges").wrap(DefaultHeaders::new().add(("Cache-Control", "no-store")).add(("Referrer-Policy", "no-referrer"))).app_data(web::JsonConfig::default().limit(1024)).route("/create", web::post().to(create)).route("/review", web::post().to(review)).route("/decide", web::post().to(decide)).route("/poll", web::post().to(poll)));
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/create", tag = "Users", request_body = CreateLoginChallengeRequest,
|
||||
responses((status = 200, body = CreateLoginChallengeResponse), (status = 429, body = CommonWebError)))]
|
||||
pub async fn create(request: HttpRequest, _body: web::Json<CreateLoginChallengeRequest>, pool: web::Data<MySqlPool>) -> Result<web::Json<CreateLoginChallengeResponse>, CommonWebError> {
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, true)?;
|
||||
let approval_token = new_secret()?;
|
||||
let device_token = new_secret()?;
|
||||
let approval_hash = secret_hash(&approval_token)?;
|
||||
let device_hash = secret_hash(&device_token)?;
|
||||
let code = new_confirmation_code()?;
|
||||
let token = UserLoginChallengeToken::generate();
|
||||
let mut conn = pool.acquire().await?;
|
||||
create_challenge(CreateChallengeArgs { token: &token, approval_hash: &approval_hash, device_hash: &device_hash, confirmation_code: &code, ip_address: &ip }, &mut conn).await?;
|
||||
let challenge = lock_by_device(&device_hash, &mut conn).await?.ok_or(CommonWebError::NotFound)?;
|
||||
Ok(web::Json(CreateLoginChallengeResponse {
|
||||
success: true,
|
||||
device_token,
|
||||
// A fragment avoids sending the approval credential to the website's HTTP logs.
|
||||
verification_url: format!("{VERIFICATION_PAGE}#approval_token={approval_token}"),
|
||||
confirmation_code: code,
|
||||
expires_at: challenge.expires_at,
|
||||
poll_interval_seconds: 5,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/review", tag = "Users", request_body = ReviewLoginChallengeRequest,
|
||||
responses((status = 200, body = ReviewLoginChallengeResponse), (status = 401, body = CommonWebError), (status = 403, body = CommonWebError)))]
|
||||
pub async fn review(request: HttpRequest, body: web::Json<ReviewLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<ReviewLoginChallengeResponse>, CommonWebError> {
|
||||
require_approval_origin(&request)?;
|
||||
rate_limit(&request_ip(&request), false)?;
|
||||
let hash = secret_hash(&body.approval_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let approver = approving_session(&request, &signer, &mut tx).await?;
|
||||
let mut challenge = lock_by_approval(&hash, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
let result = outcome(&challenge, expired)?;
|
||||
tx.commit().await?;
|
||||
Ok(web::Json(ReviewLoginChallengeResponse { success: true, status: result.status, maybe_failure_type: result.maybe_failure_type, confirmation_code: challenge.confirmation_code, requesting_ip: challenge.ip_address_creation, expires_at: challenge.expires_at, username: approver.username }))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/decide", tag = "Users", request_body = DecideLoginChallengeRequest,
|
||||
responses((status = 200, body = LoginChallengeResponse), (status = 401, body = CommonWebError), (status = 403, body = CommonWebError)))]
|
||||
pub async fn decide(request: HttpRequest, body: web::Json<DecideLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<LoginChallengeResponse>, CommonWebError> {
|
||||
require_approval_origin(&request)?;
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, false)?;
|
||||
let hash = secret_hash(&body.approval_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let approver = approving_session(&request, &signer, &mut tx).await?;
|
||||
let mut challenge = lock_by_approval(&hash, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let mut expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
if !expired && challenge.status == "pending" {
|
||||
if decide_challenge(challenge.id, &approver.user_token, &ip, body.approve, &mut tx).await? {
|
||||
challenge.status = if body.approve { "approved" } else { "failed" }.into();
|
||||
challenge.maybe_failure_type = if body.approve { None } else { Some("user_declined".into()) };
|
||||
} else {
|
||||
expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
}
|
||||
}
|
||||
// No session INSERT occurs here, including on approval. Terminal decisions are immutable.
|
||||
let result = outcome(&challenge, expired)?;
|
||||
tx.commit().await?;
|
||||
Ok(web::Json(result))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/poll", tag = "Users", request_body = PollLoginChallengeRequest,
|
||||
responses((status = 200, body = LoginChallengeResponse), (status = 401, body = CommonWebError), (status = 429, body = CommonWebError)))]
|
||||
pub async fn poll(request: HttpRequest, body: web::Json<PollLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<HttpResponse, CommonWebError> {
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, false)?;
|
||||
let hash = secret_hash(&body.device_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut challenge = lock_by_device(&hash, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
let mut result = outcome(&challenge, expired)?;
|
||||
if expired || challenge.status == "pending" || challenge.status == "failed" {
|
||||
tx.commit().await?;
|
||||
return Ok(HttpResponse::Ok().json(result));
|
||||
}
|
||||
let user = challenge.maybe_deciding_user_token.as_deref().ok_or(CommonWebError::NotAuthorized)?;
|
||||
let session = if challenge.status == "approved" {
|
||||
let session = create_bridge_session(user, &ip, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
if !mark_redeemed(challenge.id, session.id, &ip, &mut tx).await? {
|
||||
// Includes expiry during redemption. Roll back the INSERT as well.
|
||||
tx.rollback().await?;
|
||||
return Ok(HttpResponse::Ok().json(outcome(&challenge, true)?));
|
||||
}
|
||||
session
|
||||
} else if challenge.status == "redeemed" {
|
||||
let id = challenge.maybe_redeemed_user_session_id.ok_or(CommonWebError::NotAuthorized)?;
|
||||
find_redeemed_session(id, user, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?
|
||||
} else {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
};
|
||||
let cookie = signer.create_cookie(&UserSessionToken::new(session.token), &UserToken::new(session.user_token))?;
|
||||
result.status = LoginChallengeState::Redeemed;
|
||||
result.maybe_signed_session = Some(cookie.value().to_owned());
|
||||
// The new session, challenge transition, and signing must ALL succeed first.
|
||||
tx.commit().await?;
|
||||
Ok(HttpResponse::Ok().cookie(cookie).json(result))
|
||||
}
|
||||
|
||||
async fn approving_session(request: &HttpRequest, signer: &HttpUserSessionManager, conn: &mut MySqlConnection) -> Result<BridgeSession, CommonWebError> {
|
||||
let payload = signer.decode_session_payload_from_request(request).map_err(|_| CommonWebError::NotAuthorized)?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
find_approving_session(&payload.session_token, conn).await?.ok_or(CommonWebError::NotAuthorized)
|
||||
}
|
||||
|
||||
fn outcome(challenge: &LoginChallenge, expired: bool) -> Result<LoginChallengeResponse, CommonWebError> {
|
||||
// Keep an explicit decline even when its deadline has since elapsed.
|
||||
let declined = challenge.maybe_failure_type.as_deref() == Some("user_declined");
|
||||
let (status, failure) = if expired && !declined {
|
||||
(LoginChallengeState::Failed, Some(LoginChallengeFailure::Expired))
|
||||
} else {
|
||||
let status = match challenge.status.as_str() {
|
||||
"pending" => LoginChallengeState::Pending,
|
||||
"approved" => LoginChallengeState::Approved,
|
||||
"redeemed" => LoginChallengeState::Redeemed,
|
||||
"failed" => LoginChallengeState::Failed,
|
||||
_ => return Err(CommonWebError::NotAuthorized),
|
||||
};
|
||||
let failure = match challenge.maybe_failure_type.as_deref() {
|
||||
Some("user_declined") => Some(LoginChallengeFailure::UserDeclined),
|
||||
Some("expired") => Some(LoginChallengeFailure::Expired),
|
||||
None => None,
|
||||
_ => return Err(CommonWebError::NotAuthorized),
|
||||
};
|
||||
(status, failure)
|
||||
};
|
||||
Ok(LoginChallengeResponse { success: true, status, maybe_failure_type: failure, maybe_signed_session: None })
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod handlers;
|
||||
mod security;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use rand::{
|
||||
rngs::{OsRng, StdRng},
|
||||
Rng, SeedableRng, TryRngCore,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
const CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
|
||||
const APPROVAL_ORIGINS: &[&str] = &["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"];
|
||||
const WINDOW: Duration = Duration::from_secs(60);
|
||||
const MAX_BUCKETS: usize = 10_000;
|
||||
|
||||
/// Follow the existing gateway's forwarded-IP policy, but normalize stored IPs
|
||||
/// and use the socket address for direct IPv6 requests (the legacy helper splits
|
||||
/// on ':'). Gateway deployments must strip caller-supplied forwarding headers.
|
||||
pub fn request_ip(request: &HttpRequest) -> String {
|
||||
get_request_ip(request).parse::<IpAddr>().ok().or_else(|| request.peer_addr().map(|address| address.ip())).map(|ip| ip.to_string()).unwrap_or_else(|| "0.0.0.0".into())
|
||||
}
|
||||
|
||||
pub fn new_secret() -> Result<String, CommonWebError> {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.try_fill_bytes(&mut bytes).map_err(CommonWebError::from_error)?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn secret_hash(secret: &str) -> Result<[u8; 32], CommonWebError> {
|
||||
if secret.len() != 43 {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD.decode(secret).map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
if bytes.len() != 32 || URL_SAFE_NO_PAD.encode(&bytes) != secret {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
Ok(Sha256::digest(secret.as_bytes()).into())
|
||||
}
|
||||
|
||||
pub fn new_confirmation_code() -> Result<String, CommonWebError> {
|
||||
let mut rng = StdRng::try_from_os_rng().map_err(CommonWebError::from_error)?;
|
||||
Ok((0..8).map(|_| CODE_ALPHABET[rng.random_range(0..CODE_ALPHABET.len())] as char).collect())
|
||||
}
|
||||
|
||||
/// Approval is browser-only and requires an exact trusted Origin. JSON POSTs
|
||||
/// plus Origin validation prevent ambient cookies from granting cross-site consent.
|
||||
pub fn require_approval_origin(request: &HttpRequest) -> Result<(), CommonWebError> {
|
||||
let origin = request.headers().get("Origin").and_then(|v| v.to_str().ok()).ok_or(CommonWebError::Forbidden)?;
|
||||
if APPROVAL_ORIGINS.contains(&origin) {
|
||||
return Ok(());
|
||||
}
|
||||
// Local web development is permitted only when the API is also local.
|
||||
let host = request.connection_info().host().to_owned();
|
||||
let local_api = host.starts_with("localhost:") || host.starts_with("127.0.0.1:");
|
||||
if local_api && ["http://localhost:4201", "http://127.0.0.1:4201"].contains(&origin) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CommonWebError::Forbidden)
|
||||
}
|
||||
|
||||
/// Per-process abuse cap, shared by all Actix workers. Entries expire and memory
|
||||
/// is bounded. Deployments can impose stricter aggregate limits at their gateway.
|
||||
pub fn rate_limit(ip: &str, create: bool) -> Result<(), CommonWebError> {
|
||||
static BUCKETS: OnceLock<Mutex<HashMap<(String, bool), (Instant, u32)>>> = OnceLock::new();
|
||||
let mut buckets = BUCKETS.get_or_init(|| Mutex::new(HashMap::new())).lock().map_err(|_| CommonWebError::TooManyRequests)?;
|
||||
let now = Instant::now();
|
||||
buckets.retain(|_, (started, _)| now.duration_since(*started) < WINDOW);
|
||||
let key = (ip.to_owned(), create);
|
||||
if buckets.len() >= MAX_BUCKETS && !buckets.contains_key(&key) {
|
||||
return Err(CommonWebError::TooManyRequests);
|
||||
}
|
||||
let (_, count) = buckets.entry(key).or_insert((now, 0));
|
||||
let limit = if create { 10 } else { 120 };
|
||||
if *count >= limit {
|
||||
return Err(CommonWebError::TooManyRequests);
|
||||
}
|
||||
*count += 1;
|
||||
Ok(())
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
//! Real HTTP handlers + signed cookies + disposable local MySQL databases.
|
||||
//! No application URLs, production DB configuration, Redis, or external providers.
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_http::Request;
|
||||
use actix_web::body::MessageBody;
|
||||
use actix_web::dev::{Service, ServiceResponse};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{test, web, App, HttpRequest};
|
||||
use artcraft_api_defs::users::login_challenges::*;
|
||||
use mysql_queries::queries::user_login_challenges::challenge_queries::{lock_by_device, expire_abandoned_challenges};
|
||||
use mysql_testing::fixtures::login_challenges as fixtures;
|
||||
use mysql_testing::fixtures::mcp_sessions::create_test_mcp_session;
|
||||
use mysql_testing::fixtures::users::{create_test_user, TestUser};
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
|
||||
use super::{handlers, security};
|
||||
|
||||
const ORIGIN: &str = "https://app.getartcraft.com";
|
||||
const BROWSER_IP: &str = "198.51.100.20";
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn approval_mints_once_and_cookie_authenticates_downstream() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
|
||||
fixtures::set_passwordless(&h.db.pool, &h.user.user_token).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert!(!created.verification_url.contains(&created.device_token));
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.lifetime_seconds, 1200);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
|
||||
let pending = post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await;
|
||||
assert!(!pending.headers().contains_key("set-cookie"));
|
||||
assert_eq!(pending.headers().get("cache-control").unwrap(), "no-store");
|
||||
let pending: LoginChallengeResponse = test::read_body_json(pending).await;
|
||||
assert_eq!(pending.status, LoginChallengeState::Pending);
|
||||
assert!(pending.maybe_signed_session.is_none());
|
||||
assert_eq!(post(&app, &h, "review", json!({"approval_token": approval}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
// Swapping approval and device credentials cannot expose or redeem a session.
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": approval}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(post(&app, &h, "review", json!({"approval_token": created.device_token}), true).await.status(), StatusCode::UNAUTHORIZED);
|
||||
let reviewed: ReviewLoginChallengeResponse = test::read_body_json(post(&app, &h, "review", json!({"approval_token": approval}), true).await).await;
|
||||
assert_eq!(reviewed.requesting_ip, h.ip);
|
||||
assert_eq!(reviewed.confirmation_code, created.confirmation_code);
|
||||
assert_eq!(reviewed.username, h.user.username);
|
||||
|
||||
let approved = post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true).await;
|
||||
assert!(!approved.headers().contains_key("set-cookie"));
|
||||
let approved: LoginChallengeResponse = test::read_body_json(approved).await;
|
||||
assert_eq!(approved.status, LoginChallengeState::Approved);
|
||||
assert!(approved.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0, "Yes records consent only");
|
||||
|
||||
// Two polls overlap. Both must observe the single committed session.
|
||||
let (first, retry) = tokio::join!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false), post(&app, &h, "poll", json!({"device_token": created.device_token}), false));
|
||||
let cookie = first.response().cookies().find(|c| c.name() == "session").unwrap().into_owned();
|
||||
let first: LoginChallengeResponse = test::read_body_json(first).await;
|
||||
let retry: LoginChallengeResponse = test::read_body_json(retry).await;
|
||||
assert_eq!(first.status, LoginChallengeState::Redeemed);
|
||||
assert_eq!(first.maybe_signed_session, retry.maybe_signed_session, "response loss retries reuse the same session");
|
||||
assert_eq!(first.maybe_signed_session.as_deref(), Some(cookie.value()));
|
||||
assert_ne!(cookie.value(), h.cookie, "never copy the website's existing session");
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
assert!(fixtures::audit(&h.db.pool, &hash).await.session_id.is_some());
|
||||
let response = test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie.clone()).to_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let downstream: Value = test::read_body_json(response).await;
|
||||
assert_eq!(downstream["user_token"], h.user.user_token.as_str());
|
||||
|
||||
// The handoff expires in 20 minutes; a successfully issued session keeps its own lifetime.
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
let expired: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(expired.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(expired.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.status, "redeemed");
|
||||
assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie).to_request()).await.status(), StatusCode::OK);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn decline_expiry_and_invalid_consent_never_mint_sessions() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
for origin in [None, Some("https://evil.example"), Some("null")] {
|
||||
let mut request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("session", h.cookie.as_str())).set_json(json!({"approval_token": approval, "approve": true}));
|
||||
if let Some(origin) = origin {
|
||||
request = request.insert_header(("Origin", origin));
|
||||
}
|
||||
assert_eq!(test::call_service(&app, request.to_request()).await.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
let mcp = create_test_mcp_session(&h.db.pool, &h.user.user_token).await.unwrap();
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", ORIGIN)).insert_header(("Authorization", format!("Bearer {}", mcp.private_session_token.as_str()))).set_json(json!({"approval_token": approval, "approve": true})).to_request();
|
||||
assert_eq!(test::call_service(&app, request).await.status(), StatusCode::UNAUTHORIZED);
|
||||
let declined: LoginChallengeResponse = test::read_body_json(post(&app, &h, "decide", json!({"approval_token": approval, "approve": false}), true).await).await;
|
||||
assert_eq!(declined.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
// A later Yes cannot overwrite No.
|
||||
let replay: LoginChallengeResponse = test::read_body_json(post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true).await).await;
|
||||
assert_eq!(replay.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
let rejected: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(rejected.status, LoginChallengeState::Failed);
|
||||
assert!(rejected.maybe_signed_session.is_none());
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.creation_ip, h.ip);
|
||||
assert_eq!(audit.failure_ip.as_deref(), Some(BROWSER_IP));
|
||||
assert_eq!(audit.failure.as_deref(), Some("user_declined"));
|
||||
assert!(audit.session_id.is_none());
|
||||
|
||||
for approve_first in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
if approve_first {
|
||||
assert_eq!(post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await.status(), StatusCode::OK);
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, 0).await;
|
||||
let result: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.status, "failed");
|
||||
assert!(audit.failure_ip.is_none());
|
||||
}
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn expired_revoked_and_banned_sessions_fail_closed() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
|
||||
for revoke in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
let redeemed: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
let signed = redeemed.maybe_signed_session.unwrap();
|
||||
let request = test::TestRequest::default().insert_header(("session", signed.as_str())).to_http_request();
|
||||
let token = h.signer.decode_session_payload_from_request(&request).unwrap().unwrap().session_token;
|
||||
if revoke {
|
||||
fixtures::revoke_session(&h.db.pool, &token).await;
|
||||
} else {
|
||||
fixtures::expire_session(&h.db.pool, &token).await;
|
||||
}
|
||||
assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").insert_header(("session", signed)).to_request()).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let count = fixtures::bridge_session_count(&h.db.pool).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
fixtures::ban_user(&h.db.pool, &h.user.user_token).await;
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, count);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn expiry_is_checked_after_waiting_for_the_redemption_lock() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, 1).await;
|
||||
let mut lock = h.db.pool.begin().await.unwrap();
|
||||
lock_by_device(&hash, &mut lock).await.unwrap().unwrap();
|
||||
let (response, _) = tokio::join!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false), async {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
lock.commit().await.unwrap();
|
||||
});
|
||||
let result: LoginChallengeResponse = test::read_body_json(response).await;
|
||||
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn competing_decisions_are_immutable_and_abandoned_requests_expire() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/create").peer_addr("[2001:db8::42]:1234".parse().unwrap()).set_json(json!({})).to_request();
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(test::call_service(&app, request).await).await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.creation_ip, "2001:db8::42");
|
||||
let approval = approval_token(&created);
|
||||
let (yes, no) = tokio::join!(post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true), post(&app, &h, "decide", json!({"approval_token": approval, "approve": false}), true));
|
||||
let yes: LoginChallengeResponse = test::read_body_json(yes).await;
|
||||
let no: LoginChallengeResponse = test::read_body_json(no).await;
|
||||
assert_eq!(yes.status, no.status);
|
||||
assert_eq!(yes.maybe_failure_type, no.maybe_failure_type);
|
||||
assert!(matches!(yes.status, LoginChallengeState::Approved | LoginChallengeState::Failed));
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
// Sweep both pending and approved challenges even if the desktop never polls.
|
||||
for approve_first in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
if approve_first {
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
assert_eq!(expire_abandoned_challenges(&h.db.pool).await.unwrap(), 1);
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.failure.as_deref(), Some("expired"));
|
||||
assert!(audit.failure_ip.is_none());
|
||||
assert!(audit.session_id.is_none());
|
||||
}
|
||||
assert_eq!(expire_abandoned_challenges(&h.db.pool).await.unwrap(), 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn impersonated_expired_and_revoked_browser_sessions_cannot_approve() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
for mode in ["impersonated", "expired", "revoked"] {
|
||||
let user = create_test_user(&h.db.pool).await.unwrap();
|
||||
match mode {
|
||||
"impersonated" => fixtures::impersonate_session(&h.db.pool, user.session_token.as_str(), &h.user.user_token).await,
|
||||
"expired" => fixtures::expire_session(&h.db.pool, user.session_token.as_str()).await,
|
||||
_ => fixtures::revoke_session(&h.db.pool, user.session_token.as_str()).await,
|
||||
}
|
||||
let cookie = h.signer.create_cookie(&user.session_token, &user.user_token).unwrap();
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", ORIGIN)).cookie(cookie).set_json(json!({"approval_token": approval, "approve": true})).to_request();
|
||||
assert_eq!(test::call_service(&app, request).await.status(), StatusCode::UNAUTHORIZED, "{mode}");
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.status, "pending");
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
db: IsolatedTestDatabase,
|
||||
user: TestUser,
|
||||
signer: HttpUserSessionManager,
|
||||
cookie: String,
|
||||
ip: String,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn create() -> Self {
|
||||
static NEXT_IP: AtomicU16 = AtomicU16::new(1);
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let user = create_test_user(&db.pool).await.unwrap();
|
||||
let signer = HttpUserSessionManager::new("localhost", "bridge_integration_test_secret").unwrap();
|
||||
let cookie = signer.create_cookie(&user.session_token, &user.user_token).unwrap().value().to_owned();
|
||||
let ip = format!("192.0.2.{}", NEXT_IP.fetch_add(1, Ordering::Relaxed));
|
||||
Self { db, user, signer, cookie, ip }
|
||||
}
|
||||
}
|
||||
|
||||
async fn post<S, B>(app: &S, h: &Harness, action: &str, body: impl Serialize, authenticated: bool) -> ServiceResponse<B>
|
||||
where
|
||||
S: Service<Request, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||
B: MessageBody,
|
||||
{
|
||||
let ip = if action == "review" || action == "decide" { BROWSER_IP } else { &h.ip };
|
||||
let mut request = test::TestRequest::post().uri(&format!("/v1/login_challenges/{action}")).insert_header(("Origin", ORIGIN)).peer_addr(format!("{ip}:1234").parse().unwrap()).set_json(body);
|
||||
if authenticated {
|
||||
request = request.insert_header(("session", h.cookie.as_str()));
|
||||
}
|
||||
test::call_service(app, request.to_request()).await
|
||||
}
|
||||
|
||||
fn approval_token(created: &CreateLoginChallengeResponse) -> String {
|
||||
created.verification_url.split_once("#approval_token=").unwrap().1.to_owned()
|
||||
}
|
||||
|
||||
/// Probe the SAME production authentication helper used by protected endpoints,
|
||||
/// with the cookie signer and SQL lookups, rather than merely decoding a JWT.
|
||||
async fn protected(request: HttpRequest, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<Value>, CommonWebError> {
|
||||
let checker = SessionChecker::new(&signer);
|
||||
let user = require_user_session(&request, &checker, &**pool).await?;
|
||||
Ok(web::Json(json!({"user_token": user.user_token, "username": user.username})))
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod image_studio;
|
||||
pub mod inference_job;
|
||||
pub mod internal;
|
||||
pub mod mcp_sessions;
|
||||
pub mod login_challenges;
|
||||
pub mod media_files;
|
||||
pub mod misc;
|
||||
pub mod model_download;
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ pub fn add_user_routes<T, B> (app: App<T>) -> App<T>
|
||||
>,
|
||||
{
|
||||
app
|
||||
.configure(crate::http_server::endpoints::login_challenges::handlers::configure)
|
||||
.service(
|
||||
// TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal
|
||||
web::resource("/create_account")
|
||||
|
||||
@@ -55,6 +55,7 @@ use crate::startup::setup_disabled_endpoints::read_disabled_endpoints;
|
||||
use crate::startup::setup_metrics::build_metrics;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
|
||||
use crate::threads::expire_login_challenges::expire_login_challenges;
|
||||
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
|
||||
use crate::threads::poll_model_token_info_thread::poll_model_token_info_thread;
|
||||
|
||||
@@ -108,6 +109,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
// ==================== Background threads ==================== //
|
||||
|
||||
let tokio_runtime = Runtime::new()?;
|
||||
tokio_runtime.spawn(expire_login_challenges(server_state.mysql_pool.clone()));
|
||||
|
||||
info!("Spawning pager worker thread.");
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::warn;
|
||||
use mysql_queries::queries::user_login_challenges::challenge_queries::expire_abandoned_challenges;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub async fn expire_login_challenges(pool: MySqlPool) {
|
||||
loop {
|
||||
if let Err(error) = expire_abandoned_challenges(&pool).await {
|
||||
warn!("Login challenge expiration sweep failed: {}", error);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod db_health_checker_thread;
|
||||
pub mod expire_login_challenges;
|
||||
pub mod poll_ip_banlist_thread;
|
||||
pub mod poll_model_token_info_thread;
|
||||
pub mod poll_model_token_info_thread;
|
||||
@@ -2,17 +2,24 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
if (window.location.pathname === "/login/desktop") {
|
||||
const policy = document.createElement("meta");
|
||||
policy.name = "referrer";
|
||||
policy.content = "no-referrer";
|
||||
document.head.appendChild(policy);
|
||||
} else {
|
||||
const analytics = document.createElement("script");
|
||||
analytics.async = true;
|
||||
analytics.src = "https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0";
|
||||
document.head.appendChild(analytics);
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
}
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -48,6 +48,7 @@ import { SignupCtaModal } from "../components/signup-cta-modal";
|
||||
import { InsufficientCreditsModal } from "../components/insufficient-credits-modal";
|
||||
import { GlobalActionReminder } from "../components/global-action-reminder";
|
||||
import { useSession } from "../lib/session";
|
||||
import { DesktopLogin } from "../pages/login/desktop-login";
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -144,6 +145,7 @@ export function App() {
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route path="/login/desktop" element={<DesktopLogin />} />
|
||||
{/* Public — no chrome, no auth gate. login/signup share a layout route
|
||||
so the showcase pane (and its video) stays mounted when toggling
|
||||
between them. */}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AUTH_FORM_PADDING } from "./auth-form-styles";
|
||||
import { ReactNode } from "react";
|
||||
import { Outlet, Navigate, Link } from "react-router-dom";
|
||||
import { Outlet, Navigate, Link, useSearchParams } from "react-router-dom";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { AuthShowcase } from "./auth-showcase";
|
||||
import { AuthPageFrame } from "./auth-page-frame";
|
||||
import { useMediaQuery } from "../ui/use-media-query";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
|
||||
/**
|
||||
* Persistent shell for the auth pages. Rendered as a layout route so the
|
||||
@@ -15,14 +16,15 @@ import { useSession } from "../../lib/session";
|
||||
* (AuthHeader) and footer (AuthFooter) into the outlet.
|
||||
*/
|
||||
export const AuthLayout = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
// Only mount the showcase on wide screens (matches the `lg` breakpoint) so
|
||||
// mobile never downloads the demo videos.
|
||||
const showShowcase = useMediaQuery("(min-width: 1024px)");
|
||||
const { loggedIn, authChecked } = useSession();
|
||||
|
||||
// Already signed in? Never show the auth form — go straight home.
|
||||
// Preserve a safe return path when session refresh completes during login.
|
||||
if (loggedIn) {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to={safeAuthReturnPath(searchParams.get("from"))} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const LOGIN_BRIDGE_PATH = "/login/desktop";
|
||||
const STORAGE_KEY = "artcraft_pending_desktop_approval";
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
|
||||
/** Capture the fragment before referral tracking or any third-party code can see it. */
|
||||
export function captureLoginBridgeContext(): string | null {
|
||||
if (window.location.pathname !== LOGIN_BRIDGE_PATH) return null;
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
||||
const supplied = fragment.get("approval_token");
|
||||
if (supplied !== null) {
|
||||
// Remove even malformed input from the URL. It must not become attribution data.
|
||||
window.history.replaceState(window.history.state, "", LOGIN_BRIDGE_PATH);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
if (TOKEN_PATTERN.test(supplied)) sessionStorage.setItem(STORAGE_KEY, supplied);
|
||||
}
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored && TOKEN_PATTERN.test(stored) ? stored : null;
|
||||
}
|
||||
|
||||
export function clearLoginBridgeContext(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function safeAuthReturnPath(value: string | null): string {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f]/.test(value)) return "/";
|
||||
return value;
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import { captureLandingContext, getReferrer } from "@storyteller/common";
|
||||
import { setOmniGenErrorNotifier } from "@storyteller/omni-gen";
|
||||
import { setToastDelegate } from "@storyteller/ui-toaster";
|
||||
import { toast } from "./components/toast/toast";
|
||||
import { captureLoginBridgeContext, LOGIN_BRIDGE_PATH } from "./lib/login-bridge-context";
|
||||
|
||||
captureLoginBridgeContext();
|
||||
const isLoginBridge = window.location.pathname === LOGIN_BRIDGE_PATH;
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
|
||||
@@ -50,10 +54,10 @@ setToastDelegate({
|
||||
// Persist landing context (referral username, landing URL, referrer) to apex-
|
||||
// domain cookies so attribution survives the getartcraft.com →
|
||||
// app.getartcraft.com hop. First visit wins.
|
||||
captureLandingContext();
|
||||
if (!isLoginBridge) captureLandingContext();
|
||||
|
||||
// Fire-and-forget: log the referral once per browser session
|
||||
if (!sessionStorage.getItem("referral_logged")) {
|
||||
if (!isLoginBridge && !sessionStorage.getItem("referral_logged")) {
|
||||
sessionStorage.setItem("referral_logged", "1");
|
||||
const referrer = getReferrer();
|
||||
new UsersApi()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { DesktopLogin } from "./desktop-login";
|
||||
import { safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
|
||||
const { transport, auth } = vi.hoisted(() => ({ transport: vi.fn(), auth: { loggedIn: true } }));
|
||||
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport }));
|
||||
vi.mock("../../lib/session", () => ({ useSession: () => ({ loggedIn: auth.loggedIn, authChecked: true }) }));
|
||||
vi.mock("@storyteller/api", async () => ({
|
||||
...(await import("../../../../../libs/api/src/lib/LoginChallengesApi")),
|
||||
HttpApiError: (await import("../../../../../libs/api/src/lib/ApiManager")).HttpApiError,
|
||||
}));
|
||||
|
||||
const APPROVAL_TOKEN = "A".repeat(43);
|
||||
const REVIEW = {
|
||||
success: true, status: "pending", maybe_failure_type: null,
|
||||
confirmation_code: "WDJBMJHT", requesting_ip: "2001:db8::42",
|
||||
username: "google_user", expires_at: new Date(Date.now() + 1_200_000).toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
auth.loggedIn = true;
|
||||
transport.mockReset();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
window.history.replaceState(null, "", `/login/desktop#approval_token=${APPROVAL_TOKEN}`);
|
||||
transport.mockImplementation(async (url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(String(init.body));
|
||||
if (url.endsWith("/review")) return response(REVIEW);
|
||||
return response({ success: true, status: body.approve ? "approved" : "failed", maybe_failure_type: body.approve ? null : "user_declined" });
|
||||
});
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("website login approval integration", () => {
|
||||
it("reviews the real API response and requires consent plus matching code before approval", async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByText("google_user")).toBeTruthy();
|
||||
expect(screen.getByText("2001:db8::42")).toBeTruthy();
|
||||
expect(screen.getByText("WDJB-MJHT")).toBeTruthy();
|
||||
const approve = screen.getByRole("button", { name: "Approve desktop login" }) as HTMLButtonElement;
|
||||
expect(approve.disabled).toBe(true);
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
expect(window.location.hash).toBe("");
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.click(approve);
|
||||
await screen.findByText("Desktop login approved. Return to ArtCraft to finish signing in.");
|
||||
expect(JSON.parse(transport.mock.calls[1][1].body)).toEqual({ approval_token: APPROVAL_TOKEN, approve: true });
|
||||
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
|
||||
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBeNull();
|
||||
});
|
||||
|
||||
it("records decline without requiring the match checkbox or issuing a session", async () => {
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Decline" }));
|
||||
await screen.findByText("Desktop login declined. No session was created.");
|
||||
expect(JSON.parse(transport.mock.calls[1][1].body).approve).toBe(false);
|
||||
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves approval through login without putting a credential in the login URL", async () => {
|
||||
auth.loggedIn = false;
|
||||
const first = renderPage();
|
||||
await screen.findByText("/login?from=%2Flogin%2Fdesktop");
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe(APPROVAL_TOKEN);
|
||||
first.unmount();
|
||||
auth.loggedIn = true;
|
||||
renderPage();
|
||||
await screen.findByText("google_user");
|
||||
expect(JSON.parse(transport.mock.calls[0][1].body)).toEqual({ approval_token: APPROVAL_TOKEN });
|
||||
expect(screen.queryByText("Desktop login approved.")).toBeNull();
|
||||
});
|
||||
|
||||
it("prevents approving an expired request", async () => {
|
||||
transport.mockResolvedValue(response({ ...REVIEW, expires_at: new Date(Date.now() - 1000).toISOString() }));
|
||||
renderPage();
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("button", { name: "Approve desktop login" })).toBeNull();
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an invalid challenge without redirecting forever", async () => {
|
||||
transport.mockResolvedValue(new Response("{}", { status: 401 }));
|
||||
renderPage();
|
||||
await screen.findByText(/request or account session is no longer valid/);
|
||||
await waitFor(() => expect(transport).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("rejects external or protocol-relative authentication return URLs", () => {
|
||||
for (const path of ["https://evil.example", "//evil.example", "/\\evil.example", "/\nevil.example"]) {
|
||||
expect(safeAuthReturnPath(path)).toBe("/");
|
||||
}
|
||||
expect(safeAuthReturnPath("/login/desktop")).toBe("/login/desktop");
|
||||
});
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
return render(<MemoryRouter initialEntries={["/login/desktop"]}><Routes>
|
||||
<Route path="/login/desktop" element={<DesktopLogin />} />
|
||||
<Route path="/login" element={<LoginLocation />} />
|
||||
</Routes></MemoryRouter>);
|
||||
}
|
||||
|
||||
function LoginLocation() {
|
||||
const location = useLocation();
|
||||
return <p>{location.pathname}{location.search}</p>;
|
||||
}
|
||||
|
||||
function response(body: unknown) {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Navigate } from "react-router-dom";
|
||||
import { HttpApiError, LoginChallengesApi, type LoginChallengeReview } from "@storyteller/api";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { captureLoginBridgeContext, clearLoginBridgeContext, LOGIN_BRIDGE_PATH } from "../../lib/login-bridge-context";
|
||||
|
||||
export function DesktopLogin() {
|
||||
const { loggedIn, authChecked } = useSession();
|
||||
const [token] = useState(captureLoginBridgeContext);
|
||||
const [review, setReview] = useState<LoginChallengeReview | null>(null);
|
||||
const [matched, setMatched] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [now, setNow] = useState(Date.now);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !loggedIn) return;
|
||||
let active = true;
|
||||
new LoginChallengesApi().review(token).then((result) => {
|
||||
if (active) {
|
||||
setReview(result);
|
||||
if (result.status !== "pending") {
|
||||
setFinished(true);
|
||||
setMessage(result.maybe_failure_type === "expired" ? "This login request expired. Start again in ArtCraft." : "This login request has already been completed.");
|
||||
clearLoginBridgeContext();
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (!active) return;
|
||||
setMessage(error instanceof HttpApiError && error.status === 401
|
||||
? "This login request or account session is no longer valid. Start a new login in ArtCraft."
|
||||
: "Unable to load this login request. Return to ArtCraft and try again.");
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [token, loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const expired = review ? now >= Date.parse(review.expires_at) : false;
|
||||
const decide = async (approve: boolean) => {
|
||||
if (!token || busy || expired || (approve && !matched)) return;
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await new LoginChallengesApi().decide(token, approve);
|
||||
const approved = result.status === "approved" || result.status === "redeemed";
|
||||
const declined = result.status === "failed" && result.maybe_failure_type === "user_declined";
|
||||
const timedOut = result.status === "failed" && result.maybe_failure_type === "expired";
|
||||
if (!result.success || (!approved && !declined && !timedOut)) throw new Error("Unexpected login state");
|
||||
setFinished(true);
|
||||
setMessage(approved ? "Desktop login approved. Return to ArtCraft to finish signing in." : declined ? "Desktop login declined. No session was created." : "This login request expired. Start again in ArtCraft.");
|
||||
clearLoginBridgeContext();
|
||||
} catch {
|
||||
setMessage("Unable to confirm your choice. Please try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authChecked) return <main className="p-8" role="status">Checking your account…</main>;
|
||||
if (!token) return <main className="p-8">Open a new login link or scan the QR code from ArtCraft.</main>;
|
||||
if (!loggedIn) return <Navigate to={`/login?from=${encodeURIComponent(LOGIN_BRIDGE_PATH)}`} replace />;
|
||||
|
||||
return <main className="flex min-h-svh items-center justify-center bg-ui-background p-6 text-white">
|
||||
<section className="w-full max-w-md rounded-2xl border border-white/15 bg-white/5 p-8">
|
||||
<Link to="/" className="text-sm text-white/60">ArtCraft</Link>
|
||||
<h1 className="mb-4 mt-6 text-3xl font-semibold">Log in to ArtCraft Desktop?</h1>
|
||||
{message && <p role="status" className="mb-5">{message}</p>}
|
||||
{expired && !finished && <p role="alert">This request expired. Start a new login in ArtCraft.</p>}
|
||||
{!finished && !expired && review && <>
|
||||
<p>Authorize your desktop as <strong>{review.username}</strong>.</p>
|
||||
<p className="mt-3 text-sm text-white/70">Requesting IP: <code>{review.requesting_ip}</code></p>
|
||||
<p className="mt-5">Make sure this code matches the code on your desktop:</p>
|
||||
<p className="my-5 text-center font-mono text-3xl tracking-widest">{review.confirmation_code.slice(0, 4)}-{review.confirmation_code.slice(4)}</p>
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input type="checkbox" checked={matched} onChange={(event) => setMatched(event.target.checked)} />
|
||||
<span>I started this request on my desktop and the codes match.</span>
|
||||
</label>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button className="flex-1 rounded-lg bg-white px-4 py-3 font-medium text-black disabled:opacity-40" disabled={!matched || busy} onClick={() => decide(true)}>Approve desktop login</button>
|
||||
<button className="rounded-lg border border-white/30 px-4 py-3" disabled={busy} onClick={() => decide(false)}>Decline</button>
|
||||
</div>
|
||||
</>}
|
||||
{!review && !message && <p role="status">Loading login request…</p>}
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import Login from "./login";
|
||||
import { AuthLayout } from "../../components/auth/auth-layout";
|
||||
|
||||
const { transport, refresh, billing, auth } = vi.hoisted(() => ({
|
||||
transport: vi.fn(), refresh: vi.fn(), billing: vi.fn(), auth: { loggedIn: false },
|
||||
}));
|
||||
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport }));
|
||||
vi.mock("@storyteller/api", async () => await import("../../../../../libs/api/src/lib/UsersApi"));
|
||||
vi.mock("../../lib/session", () => ({
|
||||
refreshSession: refresh,
|
||||
useSession: () => ({ loggedIn: auth.loggedIn, authChecked: true }),
|
||||
}));
|
||||
vi.mock("../../lib/billing", () => ({ hasActiveSubscription: billing }));
|
||||
vi.mock("../../components/auth", async () => ({
|
||||
...(await import("../../components/auth/GoogleLoginButton")),
|
||||
AuthHeader: () => null,
|
||||
AuthFooter: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@react-oauth/google", () => ({ GoogleLogin: ({ onSuccess }: any) => <button onClick={() => onSuccess({ credential: "google_test_credential" })}>Google sign in</button> }));
|
||||
vi.mock("@storyteller/common", () => ({ getLandingUrl: () => undefined, getReferralCode: () => undefined, getReferralUsername: () => undefined, getReferrer: () => undefined }));
|
||||
vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null }));
|
||||
vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, ...props }: any) => <button {...props}>{children}</button> }));
|
||||
vi.mock("@storyteller/ui-input", () => ({ Input: ({ inputClassName, ...props }: any) => <input {...props} /> }));
|
||||
vi.mock("../../components/seo", () => ({ default: () => null }));
|
||||
vi.mock("../../components/auth/auth-showcase", () => ({ AuthShowcase: () => null }));
|
||||
vi.mock("../../components/auth/auth-page-frame", () => ({ AuthPageFrame: ({ children }: any) => <div>{children}</div> }));
|
||||
vi.mock("../../components/ui/use-media-query", () => ({ useMediaQuery: () => false }));
|
||||
|
||||
beforeEach(() => {
|
||||
auth.loggedIn = false;
|
||||
transport.mockReset();
|
||||
refresh.mockReset().mockResolvedValue(undefined);
|
||||
billing.mockReset().mockResolvedValue(false);
|
||||
transport.mockResolvedValue(new Response(JSON.stringify({ success: true, signed_session: "signed_web_session" }), { status: 200 }));
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
sessionStorage.setItem("artcraft_pending_desktop_approval", "A".repeat(43));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Google login bridge continuation", () => {
|
||||
it("returns an unsubscribed Google user to explicit desktop consent without passing the bridge token to SSO", async () => {
|
||||
renderRoutes(<Login />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Google sign in" }));
|
||||
await screen.findByText("/login/desktop");
|
||||
expect(refresh).toHaveBeenCalledWith(true);
|
||||
expect(billing).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(transport.mock.calls[0][1].body)).toEqual({ google_credential: "google_test_credential" });
|
||||
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe("A".repeat(43));
|
||||
});
|
||||
|
||||
it("preserves the consent destination when the auth layout sees the refreshed session first", async () => {
|
||||
auth.loggedIn = true;
|
||||
renderRoutes(<AuthLayout />);
|
||||
await screen.findByText("/login/desktop");
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function renderRoutes(login: React.ReactNode) {
|
||||
return render(<MemoryRouter initialEntries={["/login?from=%2Flogin%2Fdesktop"]}><Routes>
|
||||
<Route path="/login" element={login} />
|
||||
<Route path="*" element={<Location />} />
|
||||
</Routes></MemoryRouter>);
|
||||
}
|
||||
function Location() {
|
||||
return <p>{useLocation().pathname}</p>;
|
||||
}
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
import Seo from "../../components/seo";
|
||||
import { refreshSession } from "../../lib/session";
|
||||
import { hasActiveSubscription } from "../../lib/billing";
|
||||
import { LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const fromParam = searchParams.get("from");
|
||||
const redirectTo = fromParam && fromParam.startsWith("/") ? fromParam : "/";
|
||||
const redirectTo = safeAuthReturnPath(fromParam);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -51,6 +52,11 @@ const Login = () => {
|
||||
};
|
||||
|
||||
const handleGoogleSuccess = async () => {
|
||||
if (redirectTo === LOGIN_BRIDGE_PATH) {
|
||||
await refreshSession(true);
|
||||
navigate(redirectTo);
|
||||
return;
|
||||
}
|
||||
// Refresh the session (so the app sees the new cookie) and check the
|
||||
// subscription in parallel; users without one are pushed to pricing.
|
||||
const [, subscribed] = await Promise.all([
|
||||
|
||||
@@ -21,6 +21,7 @@ export * from "./lib/PasswordResetApi.js";
|
||||
export * from "./lib/UserApiKeysApi.js";
|
||||
export * from "./lib/UserReferralCodesApi.js";
|
||||
export * from "./lib/UsersApi.js";
|
||||
export * from "./lib/LoginChallengesApi.js";
|
||||
export * from "./lib/VideoApi.js";
|
||||
export * from "./lib/WeightsApi.js";
|
||||
export * from "./lib/GetCdnOrigin.js";
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ApiManager, storeSignedSession } from "./ApiManager.js";
|
||||
|
||||
export interface LoginChallenge {
|
||||
success: boolean;
|
||||
device_token: string;
|
||||
verification_url: string;
|
||||
confirmation_code: string;
|
||||
expires_at: string;
|
||||
poll_interval_seconds: number;
|
||||
}
|
||||
|
||||
export interface LoginChallengeResult {
|
||||
success: boolean;
|
||||
// Kept open to future values: callers must fail closed on an unknown state.
|
||||
status: string;
|
||||
maybe_failure_type: string | null;
|
||||
maybe_signed_session?: string;
|
||||
}
|
||||
|
||||
export interface LoginChallengeReview extends LoginChallengeResult {
|
||||
confirmation_code: string;
|
||||
requesting_ip: string;
|
||||
expires_at: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export class LoginChallengesApi extends ApiManager {
|
||||
create(): Promise<LoginChallenge> {
|
||||
return this.request("create", {});
|
||||
}
|
||||
|
||||
review(approvalToken: string): Promise<LoginChallengeReview> {
|
||||
return this.request("review", { approval_token: approvalToken });
|
||||
}
|
||||
|
||||
decide(approvalToken: string, approve: boolean): Promise<LoginChallengeResult> {
|
||||
return this.request("decide", { approval_token: approvalToken, approve });
|
||||
}
|
||||
|
||||
poll(deviceToken: string): Promise<LoginChallengeResult> {
|
||||
return this.request("poll", { device_token: deviceToken });
|
||||
}
|
||||
|
||||
acceptSession(result: LoginChallengeResult): void {
|
||||
if (result.status !== "redeemed" || !result.success || !result.maybe_signed_session) {
|
||||
throw new Error("The desktop login has not completed.");
|
||||
}
|
||||
storeSignedSession(result.maybe_signed_session);
|
||||
}
|
||||
|
||||
private request<T>(action: string, body: Record<string, unknown>): Promise<T> {
|
||||
return this.fetch(`${this.getApiSchemeAndHost()}/v1/login_challenges/${action}`, {
|
||||
method: "POST", body,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user