diff --git a/.sqlx/query-860645e70bdf20ba6ee272cb506095bb5439c5b4b98e4ee8bc0e4695a057819c.json b/.sqlx/query-06dc6f19f013fe24ffd32a313b66dd10a87498bf17c9a63b1ece87e338b0dc74.json similarity index 77% rename from .sqlx/query-860645e70bdf20ba6ee272cb506095bb5439c5b4b98e4ee8bc0e4695a057819c.json rename to .sqlx/query-06dc6f19f013fe24ffd32a313b66dd10a87498bf17c9a63b1ece87e338b0dc74.json index 29bcd6773..5ce4f563d 100644 --- a/.sqlx/query-860645e70bdf20ba6ee272cb506095bb5439c5b4b98e4ee8bc0e4695a057819c.json +++ b/.sqlx/query-06dc6f19f013fe24ffd32a313b66dd10a87498bf17c9a63b1ece87e338b0dc74.json @@ -23,11 +23,11 @@ } }, { - "name": "status", + "name": "status: UserLoginChallengeStatus", "ordinal": 2, "type_info": { "char_set": 224, - "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", "max_size": 64, "type": "VarString" } @@ -53,7 +53,7 @@ } }, { - "name": "maybe_failure_type", + "name": "maybe_failure_type: UserLoginChallengeFailureType", "ordinal": 5, "type_info": { "char_set": 224, @@ -97,6 +97,6 @@ "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" + "hash": "06dc6f19f013fe24ffd32a313b66dd10a87498bf17c9a63b1ece87e338b0dc74", + "query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE\n" } diff --git a/.sqlx/query-1af20e0d9a5630302f987aea4847b65a34d6c412245f7fbf9c6fb7b513d7324b.json b/.sqlx/query-1af20e0d9a5630302f987aea4847b65a34d6c412245f7fbf9c6fb7b513d7324b.json new file mode 100644 index 000000000..6823685e9 --- /dev/null +++ b/.sqlx/query-1af20e0d9a5630302f987aea4847b65a34d6c412245f7fbf9c6fb7b513d7324b.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 5 + } + }, + "hash": "1af20e0d9a5630302f987aea4847b65a34d6c412245f7fbf9c6fb7b513d7324b", + "query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE id = ? AND status IN (?, ?) AND expires_at <= NOW()\n" +} diff --git a/.sqlx/query-5d762de2f24028e9b35b2fcba76dc5680d532ea8de6443c60311821b6a3e6395.json b/.sqlx/query-2ddd5589138c822385b39dde58fc0d44ffa003b09018f1799e243d8f09530981.json similarity index 56% rename from .sqlx/query-5d762de2f24028e9b35b2fcba76dc5680d532ea8de6443c60311821b6a3e6395.json rename to .sqlx/query-2ddd5589138c822385b39dde58fc0d44ffa003b09018f1799e243d8f09530981.json index 478206953..151284670 100644 --- a/.sqlx/query-5d762de2f24028e9b35b2fcba76dc5680d532ea8de6443c60311821b6a3e6395.json +++ b/.sqlx/query-2ddd5589138c822385b39dde58fc0d44ffa003b09018f1799e243d8f09530981.json @@ -4,9 +4,9 @@ "columns": [], "nullable": [], "parameters": { - "Right": 5 + "Right": 6 } }, - "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 " + "hash": "2ddd5589138c822385b39dde58fc0d44ffa003b09018f1799e243d8f09530981", + "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 (?, ?, ?, ?, ?, ?)\n " } diff --git a/.sqlx/query-86d8d6a7ffbf6880bcc6d74f31f220ac2e27d9b9b01ee98030a8f4acab849adb.json b/.sqlx/query-4d3666433b8100c9b5f351c2be0faa982127c5bb4431ef14e84ff11c5a2fd9ff.json similarity index 58% rename from .sqlx/query-86d8d6a7ffbf6880bcc6d74f31f220ac2e27d9b9b01ee98030a8f4acab849adb.json rename to .sqlx/query-4d3666433b8100c9b5f351c2be0faa982127c5bb4431ef14e84ff11c5a2fd9ff.json index d34c541ba..6b285efb4 100644 --- a/.sqlx/query-86d8d6a7ffbf6880bcc6d74f31f220ac2e27d9b9b01ee98030a8f4acab849adb.json +++ b/.sqlx/query-4d3666433b8100c9b5f351c2be0faa982127c5bb4431ef14e84ff11c5a2fd9ff.json @@ -4,9 +4,9 @@ "columns": [], "nullable": [], "parameters": { - "Right": 3 + "Right": 4 } }, - "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 " + "hash": "4d3666433b8100c9b5f351c2be0faa982127c5bb4431ef14e84ff11c5a2fd9ff", + "query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n " } diff --git a/.sqlx/query-b22b2a775ddce30c04a7d89f34a606782a695b00e3c78d3891b6c0642d2c6c4c.json b/.sqlx/query-705c952102e32df11ba98ec39a3c7943bdf633947c32d4e8f7d77a861560b1bd.json similarity index 78% rename from .sqlx/query-b22b2a775ddce30c04a7d89f34a606782a695b00e3c78d3891b6c0642d2c6c4c.json rename to .sqlx/query-705c952102e32df11ba98ec39a3c7943bdf633947c32d4e8f7d77a861560b1bd.json index 7813b87dd..9484c297c 100644 --- a/.sqlx/query-b22b2a775ddce30c04a7d89f34a606782a695b00e3c78d3891b6c0642d2c6c4c.json +++ b/.sqlx/query-705c952102e32df11ba98ec39a3c7943bdf633947c32d4e8f7d77a861560b1bd.json @@ -50,9 +50,9 @@ false ], "parameters": { - "Right": 2 + "Right": 3 } }, - "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" + "hash": "705c952102e32df11ba98ec39a3c7943bdf633947c32d4e8f7d77a861560b1bd", + "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 = ?\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" } diff --git a/.sqlx/query-7e9bc784634f436a8bf898223a5ddeea57886b7c248d82e782dd3289c23cbaec.json b/.sqlx/query-7e9bc784634f436a8bf898223a5ddeea57886b7c248d82e782dd3289c23cbaec.json new file mode 100644 index 000000000..b192a94f1 --- /dev/null +++ b/.sqlx/query-7e9bc784634f436a8bf898223a5ddeea57886b7c248d82e782dd3289c23cbaec.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 5 + } + }, + "hash": "7e9bc784634f436a8bf898223a5ddeea57886b7c248d82e782dd3289c23cbaec", + "query": "\nUPDATE user_login_challenges SET status = ?, maybe_redeemed_user_session_id = ?,\n maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()\nWHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL\n" +} diff --git a/.sqlx/query-50b3c3734a0f679cb9308d382656c6a81b14bb5b2874fa8ce5b27d56722db4a3.json b/.sqlx/query-ab9e5c27ce0c460aa6d5d1b202829c2082fa0701773582d22aae05a9d376d4a6.json similarity index 58% rename from .sqlx/query-50b3c3734a0f679cb9308d382656c6a81b14bb5b2874fa8ce5b27d56722db4a3.json rename to .sqlx/query-ab9e5c27ce0c460aa6d5d1b202829c2082fa0701773582d22aae05a9d376d4a6.json index f7706a3f0..a94b54a2f 100644 --- a/.sqlx/query-50b3c3734a0f679cb9308d382656c6a81b14bb5b2874fa8ce5b27d56722db4a3.json +++ b/.sqlx/query-ab9e5c27ce0c460aa6d5d1b202829c2082fa0701773582d22aae05a9d376d4a6.json @@ -4,9 +4,9 @@ "columns": [], "nullable": [], "parameters": { - "Right": 3 + "Right": 4 } }, - "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 " + "hash": "ab9e5c27ce0c460aa6d5d1b202829c2082fa0701773582d22aae05a9d376d4a6", + "query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n " } diff --git a/.sqlx/query-7c110983a6f4836b451ef288ce375122ca25ff104c31495f9defefae0252953c.json b/.sqlx/query-c0c85c8c55fb494d44efa6f1f2d37491c740d0ac897dcfb7a7e737bb01b824d6.json similarity index 77% rename from .sqlx/query-7c110983a6f4836b451ef288ce375122ca25ff104c31495f9defefae0252953c.json rename to .sqlx/query-c0c85c8c55fb494d44efa6f1f2d37491c740d0ac897dcfb7a7e737bb01b824d6.json index 48f8a2e9b..ecc3fe86d 100644 --- a/.sqlx/query-7c110983a6f4836b451ef288ce375122ca25ff104c31495f9defefae0252953c.json +++ b/.sqlx/query-c0c85c8c55fb494d44efa6f1f2d37491c740d0ac897dcfb7a7e737bb01b824d6.json @@ -23,11 +23,11 @@ } }, { - "name": "status", + "name": "status: UserLoginChallengeStatus", "ordinal": 2, "type_info": { "char_set": 224, - "flags": "NOT_NULL | MULTIPLE_KEY | BINARY", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", "max_size": 64, "type": "VarString" } @@ -53,7 +53,7 @@ } }, { - "name": "maybe_failure_type", + "name": "maybe_failure_type: UserLoginChallengeFailureType", "ordinal": 5, "type_info": { "char_set": 224, @@ -97,6 +97,6 @@ "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" + "hash": "c0c85c8c55fb494d44efa6f1f2d37491c740d0ac897dcfb7a7e737bb01b824d6", + "query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE\n" } diff --git a/.sqlx/query-465853868fd16271a922960fa382fa062fc64572fc1c39529ff7dbd8951c3153.json b/.sqlx/query-cc29378fa77e7f09ca2ca0803127d111703a4edca24acfe3ccecf79c22fc1a80.json similarity index 53% rename from .sqlx/query-465853868fd16271a922960fa382fa062fc64572fc1c39529ff7dbd8951c3153.json rename to .sqlx/query-cc29378fa77e7f09ca2ca0803127d111703a4edca24acfe3ccecf79c22fc1a80.json index ed6acb610..7f54b1bc3 100644 --- a/.sqlx/query-465853868fd16271a922960fa382fa062fc64572fc1c39529ff7dbd8951c3153.json +++ b/.sqlx/query-cc29378fa77e7f09ca2ca0803127d111703a4edca24acfe3ccecf79c22fc1a80.json @@ -4,9 +4,9 @@ "columns": [], "nullable": [], "parameters": { - "Right": 5 + "Right": 6 } }, - "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" + "hash": "cc29378fa77e7f09ca2ca0803127d111703a4edca24acfe3ccecf79c22fc1a80", + "query": "\nINSERT INTO user_login_challenges\n (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, created_at, expires_at)\nVALUES (?, ?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)\n" } diff --git a/.sqlx/query-d0067b5e1b3ef5dec5572eb2c0741d129ab2f706db4bb9f37a32bb89aa44fa4f.json b/.sqlx/query-d0067b5e1b3ef5dec5572eb2c0741d129ab2f706db4bb9f37a32bb89aa44fa4f.json new file mode 100644 index 000000000..5089ee9c8 --- /dev/null +++ b/.sqlx/query-d0067b5e1b3ef5dec5572eb2c0741d129ab2f706db4bb9f37a32bb89aa44fa4f.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 4 + } + }, + "hash": "d0067b5e1b3ef5dec5572eb2c0741d129ab2f706db4bb9f37a32bb89aa44fa4f", + "query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000\n" +} diff --git a/.sqlx/query-d68616cd92f80feedf269e04c8d96087f0c44ee12b8cc2768793095e39fd5a35.json b/.sqlx/query-d68616cd92f80feedf269e04c8d96087f0c44ee12b8cc2768793095e39fd5a35.json deleted file mode 100644 index 1d602112e..000000000 --- a/.sqlx/query-d68616cd92f80feedf269e04c8d96087f0c44ee12b8cc2768793095e39fd5a35.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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" -} diff --git a/.sqlx/query-d84ac4de934fc5f53e6f202e23329f6db3c29939dfec0bcd29612a8cd9e2f0ce.json b/.sqlx/query-d84ac4de934fc5f53e6f202e23329f6db3c29939dfec0bcd29612a8cd9e2f0ce.json deleted file mode 100644 index 50e56cddf..000000000 --- a/.sqlx/query-d84ac4de934fc5f53e6f202e23329f6db3c29939dfec0bcd29612a8cd9e2f0ce.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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" -} diff --git a/.sqlx/query-fb5ceb1ebb2d7f424c590836842948b19102cc583749621e338ccbb23a984b3d.json b/.sqlx/query-da64968638e564d28ed180270df407157f6c9b3f27c3443417f30c12c158ab89.json similarity index 71% rename from .sqlx/query-fb5ceb1ebb2d7f424c590836842948b19102cc583749621e338ccbb23a984b3d.json rename to .sqlx/query-da64968638e564d28ed180270df407157f6c9b3f27c3443417f30c12c158ab89.json index 6ea8fbafb..b370d7b06 100644 --- a/.sqlx/query-fb5ceb1ebb2d7f424c590836842948b19102cc583749621e338ccbb23a984b3d.json +++ b/.sqlx/query-da64968638e564d28ed180270df407157f6c9b3f27c3443417f30c12c158ab89.json @@ -4,9 +4,9 @@ "columns": [], "nullable": [], "parameters": { - "Right": 7 + "Right": 8 } }, - "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" + "hash": "da64968638e564d28ed180270df407157f6c9b3f27c3443417f30c12c158ab89", + "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 = ? AND expires_at > NOW()\n" } diff --git a/.sqlx/query-db26c830f0b7bb64f8e9bce62eee82f3844c62d8de32f9e76590c27a9c2b3b74.json b/.sqlx/query-db26c830f0b7bb64f8e9bce62eee82f3844c62d8de32f9e76590c27a9c2b3b74.json deleted file mode 100644 index 3c5dd6ad1..000000000 --- a/.sqlx/query-db26c830f0b7bb64f8e9bce62eee82f3844c62d8de32f9e76590c27a9c2b3b74.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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" -} diff --git a/.sqlx/query-e82bcef4f8ac327c3f53be4beb0568f8bc1523f91a46a8d883560872c84d1e23.json b/.sqlx/query-e82bcef4f8ac327c3f53be4beb0568f8bc1523f91a46a8d883560872c84d1e23.json deleted file mode 100644 index db00f0a41..000000000 --- a/.sqlx/query-e82bcef4f8ac327c3f53be4beb0568f8bc1523f91a46a8d883560872c84d1e23.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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" -} diff --git a/.sqlx/query-eb5fdd0e93978edfbe97c58238ea81f0c21dba047975beb53a39224894e9d41c.json b/.sqlx/query-eb5fdd0e93978edfbe97c58238ea81f0c21dba047975beb53a39224894e9d41c.json new file mode 100644 index 000000000..bfa4cbe38 --- /dev/null +++ b/.sqlx/query-eb5fdd0e93978edfbe97c58238ea81f0c21dba047975beb53a39224894e9d41c.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 4 + } + }, + "hash": "eb5fdd0e93978edfbe97c58238ea81f0c21dba047975beb53a39224894e9d41c", + "query": "\nINSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)\nSELECT ?, token, ?, ?, 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" +} diff --git a/_database/sql/migrations/2026-09-25-120000-0000_create_user_login_challenges/up.sql b/_database/sql/migrations/2026-09-25-120000-0000_create_user_login_challenges/up.sql index 239597117..f6f826d6e 100644 --- a/_database/sql/migrations/2026-09-25-120000-0000_create_user_login_challenges/up.sql +++ b/_database/sql/migrations/2026-09-25-120000-0000_create_user_login_challenges/up.sql @@ -28,7 +28,8 @@ CREATE TABLE user_login_challenges ( -- 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', + -- Application inserts explicitly bind UserLoginChallengeStatus::Pending. + status VARCHAR(16) NOT NULL, -- 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. diff --git a/_database/sql/migrations/AGENTS.md b/_database/sql/migrations/AGENTS.md index 4f80e91ee..23d2a0a0b 100644 --- a/_database/sql/migrations/AGENTS.md +++ b/_database/sql/migrations/AGENTS.md @@ -24,6 +24,11 @@ These are created with the `diesel` tool, and in particular: - Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs. - Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`. +- Application-owned state/status/type columns must not have schema defaults. Use + `NOT NULL` without `DEFAULT` when required, and have application inserts explicitly + supply the value. Document the corresponding Rust enum; application queries bind its + variants instead of hardcoding enum strings in SQL. See + `crates/schema/database/mysql_queries/AGENTS.md` for the query rules. - All SQL files should start with the standard IntelliJ noinspection comments: ```sql -- noinspection SqlDialectInspectionForFile diff --git a/_database/sql/migrations_squashed/AGENTS.md b/_database/sql/migrations_squashed/AGENTS.md index 95248459c..786545407 100644 --- a/_database/sql/migrations_squashed/AGENTS.md +++ b/_database/sql/migrations_squashed/AGENTS.md @@ -13,6 +13,9 @@ Update the corresponding squashed file whenever a migration changes a table's sc - Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs. - Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`. +- Application-owned state/status/type columns must not have schema defaults. Required + values stay `NOT NULL` and are supplied explicitly by application code using the + corresponding Rust enum. Keep these definitions consistent with the applied migrations. - All SQL files should start with the standard IntelliJ noinspection comments: ```sql -- noinspection SqlDialectInspectionForFile diff --git a/_database/sql/migrations_squashed/user_tables/user_login_challenges.sql b/_database/sql/migrations_squashed/user_tables/user_login_challenges.sql index 239597117..f6f826d6e 100644 --- a/_database/sql/migrations_squashed/user_tables/user_login_challenges.sql +++ b/_database/sql/migrations_squashed/user_tables/user_login_challenges.sql @@ -28,7 +28,8 @@ CREATE TABLE user_login_challenges ( -- 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', + -- Application inserts explicitly bind UserLoginChallengeStatus::Pending. + status VARCHAR(16) NOT NULL, -- 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. diff --git a/_database/sql/user_login_challenges.md b/_database/sql/user_login_challenges.md index c7fddd73e..608970c57 100644 --- a/_database/sql/user_login_challenges.md +++ b/_database/sql/user_login_challenges.md @@ -17,7 +17,8 @@ It is an ArtCraft session bridge, not an OAuth token endpoint implementation. 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. + needed for one immutable outcome per attempt. Status is `NOT NULL` without a + default; inserts explicitly bind `UserLoginChallengeStatus::Pending`. 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. diff --git a/crates/schema/database/mysql_queries/AGENTS.md b/crates/schema/database/mysql_queries/AGENTS.md index 4033df144..e9cf1d682 100644 --- a/crates/schema/database/mysql_queries/AGENTS.md +++ b/crates/schema/database/mysql_queries/AGENTS.md @@ -56,6 +56,20 @@ schema metadata. For **new tables**, you may need to: - Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs. - Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`. +- Do not give application-owned state/status/type columns schema defaults. Keep required + columns `NOT NULL` without a `DEFAULT`; application code must explicitly bind their + initial value on every insert. Keep migrations and materialized schema docs in sync. + +### Database enum values + +- Use the Rust enums in `enums::by_table` for database state, status, failure, and type + values. Do not hardcode their string representations in SQL or Rust business logic. +- Bind enum values (or their `to_str()` representation) through SQL parameters, including + `SET`, `WHERE`, `IN`, and `INSERT` values. For example, bind + `UserLoginChallengeStatus::Approved.to_str()` to `status = ?`. +- Query args and returned rows must use the enum type rather than `String`/`&str` for + enum columns. Use SQLx column type overrides and the enum's MySQL decoder on reads; + use exhaustive variant matches in application logic. Preserve existing stored values. ### Error Types diff --git a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/create_challenge.rs b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/create_challenge.rs index 89f99287b..524b04cfa 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/create_challenge.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/create_challenge.rs @@ -1,3 +1,4 @@ +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use sqlx::{Executor, MySql}; use tokens::tokens::user_login_challenges::UserLoginChallengeToken; @@ -7,6 +8,7 @@ pub struct CreateChallengeArgs<'a, T> { pub device_hash: &'a [u8], pub confirmation_code: &'a str, pub ip_address: &'a str, + pub status: UserLoginChallengeStatus, pub mysql_executor: T, } @@ -17,14 +19,15 @@ where 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) + (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, 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 + args.ip_address, + args.status.to_str() ) .execute(args.mysql_executor) .await?; diff --git a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/decide_challenge.rs b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/decide_challenge.rs index f65b828d2..1c0f7351b 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/decide_challenge.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/decide_challenge.rs @@ -1,3 +1,5 @@ +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use sqlx::{Executor, MySql}; pub struct DecideChallengeArgs<'a, T> { @@ -12,23 +14,24 @@ pub async fn decide_challenge<'c, T>(args: DecideChallengeArgs<'_, T>) -> Result where T: Executor<'c, Database = MySql>, { - let status = if args.approve { "approved" } else { "failed" }; - let failure = if args.approve { None } else { Some("user_declined") }; + let status = if args.approve { UserLoginChallengeStatus::Approved } else { UserLoginChallengeStatus::Failed }; + let failure = if args.approve { None } else { Some(UserLoginChallengeFailureType::UserDeclined) }; let failure_ip = if args.approve { None } else { Some(args.ip_address) }; 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() +WHERE id = ? AND status = ? AND expires_at > NOW() "#, - status, + status.to_str(), args.user_token, args.ip_address, - failure, + failure.map(|value| value.to_str()), failure_ip, args.approve, - args.challenge_id + args.challenge_id, + UserLoginChallengeStatus::Pending.to_str() ) .execute(args.mysql_executor) .await? diff --git a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/expire_abandoned_challenges.rs b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/expire_abandoned_challenges.rs index 065041627..60da8bfb9 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/expire_abandoned_challenges.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/expire_abandoned_challenges.rs @@ -1,3 +1,5 @@ +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use sqlx::{Executor, MySql}; pub struct ExpireAbandonedChallengesArgs { @@ -11,9 +13,13 @@ where { let result = 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 -"# +UPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at +WHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000 +"#, + UserLoginChallengeStatus::Failed.to_str(), + UserLoginChallengeFailureType::Expired.to_str(), + UserLoginChallengeStatus::Pending.to_str(), + UserLoginChallengeStatus::Approved.to_str() ) .execute(args.mysql_executor) .await?; diff --git a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/expire_challenge.rs b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/expire_challenge.rs index fe1a9e4fd..969fb0124 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/expire_challenge.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/expire_challenge.rs @@ -1,3 +1,5 @@ +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use sqlx::{Executor, MySql}; pub struct ExpireChallengeArgs { @@ -12,10 +14,14 @@ where { 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() +UPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at +WHERE id = ? AND status IN (?, ?) AND expires_at <= NOW() "#, - args.challenge_id + UserLoginChallengeStatus::Failed.to_str(), + UserLoginChallengeFailureType::Expired.to_str(), + args.challenge_id, + UserLoginChallengeStatus::Pending.to_str(), + UserLoginChallengeStatus::Approved.to_str() ) .execute(args.mysql_executor) .await?; diff --git a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/lock_by_approval.rs b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/lock_by_approval.rs index a79f7a8ed..11a8d0de2 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/lock_by_approval.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/lock_by_approval.rs @@ -1,3 +1,5 @@ +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use sqlx::{Executor, MySql}; use super::login_challenge::LoginChallenge; @@ -16,8 +18,8 @@ where 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 +SELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id, + maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at FROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE "#, args.approval_hash diff --git a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/lock_by_device.rs b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/lock_by_device.rs index aee6b0e09..16e618052 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/lock_by_device.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/lock_by_device.rs @@ -1,3 +1,5 @@ +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use sqlx::{Executor, MySql}; use super::login_challenge::LoginChallenge; @@ -15,8 +17,8 @@ where 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 +SELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id, + maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at FROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE "#, args.device_hash diff --git a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/login_challenge.rs b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/login_challenge.rs index b772ae10c..1b92652fd 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/login_challenge.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/login_challenge.rs @@ -1,12 +1,14 @@ use chrono::{DateTime, Utc}; +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; pub struct LoginChallenge { pub id: u64, pub confirmation_code: String, - pub status: String, + pub status: UserLoginChallengeStatus, pub maybe_deciding_user_token: Option, pub maybe_redeemed_user_session_id: Option, - pub maybe_failure_type: Option, + pub maybe_failure_type: Option, pub ip_address_creation: String, pub expires_at: DateTime, } diff --git a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/mark_redeemed.rs b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/mark_redeemed.rs index 45d6ab486..6c6a701f0 100644 --- a/crates/schema/database/mysql_queries/src/queries/user_login_challenges/mark_redeemed.rs +++ b/crates/schema/database/mysql_queries/src/queries/user_login_challenges/mark_redeemed.rs @@ -1,3 +1,4 @@ +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use sqlx::{Executor, MySql}; pub struct MarkRedeemedArgs<'a, T> { @@ -13,13 +14,15 @@ where { let affected = sqlx::query!( r#" -UPDATE user_login_challenges SET status = 'redeemed', maybe_redeemed_user_session_id = ?, +UPDATE user_login_challenges SET status = ?, 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 +WHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL "#, + UserLoginChallengeStatus::Redeemed.to_str(), args.session_id, args.ip_address, - args.challenge_id + args.challenge_id, + UserLoginChallengeStatus::Approved.to_str() ) .execute(args.mysql_executor) .await? diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_bridge_session.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_bridge_session.rs index 8a14fab53..ea101a60b 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_bridge_session.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_bridge_session.rs @@ -1,3 +1,4 @@ +use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType; use sqlx::{Executor, MySql}; use tokens::tokens::user_sessions::UserSessionToken; @@ -18,11 +19,12 @@ where 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 +SELECT ?, token, ?, ?, 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(), args.ip_address, + UserWebSessionCreationType::DeviceApproval.to_str(), args.user_token ) .execute(args.mysql_executor) diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs index e1a5b3bfa..b2bf79c63 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs @@ -1,6 +1,7 @@ use std::marker::PhantomData; use chrono::{DateTime, Utc}; +use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType; use sqlx::{Executor, MySql}; use tokens::tokens::user_sessions::UserSessionToken; @@ -39,12 +40,13 @@ INSERT INTO user_sessions ( maybe_creation_type, expires_at ) -VALUES (?, ?, ?, ?, 'impersonation', ?) +VALUES (?, ?, ?, ?, ?, ?) "#, session_token.as_str(), args.user_token.as_str(), args.impersonator_user_token.as_str(), args.ip_address, + UserWebSessionCreationType::Impersonation.to_str(), args.expires_at, ) .execute(args.mysql_executor) diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session.rs index 45690ee19..73e48b45d 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session.rs @@ -1,4 +1,5 @@ use anyhow::anyhow; +use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType; use log::info; use sqlx::MySqlPool; @@ -20,11 +21,12 @@ INSERT INTO user_sessions ( maybe_creation_type, expires_at ) -VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year ) +VALUES ( ?, ?, ?, ?, NOW() + interval 1 year ) "#, session_token, user_token.to_string(), ip_address.to_string(), + UserWebSessionCreationType::DirectLogin.to_str(), ) .execute(mysql_pool) .await; diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session_with_executor.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session_with_executor.rs index 3502649d6..02153804c 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session_with_executor.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session_with_executor.rs @@ -1,3 +1,4 @@ +use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType; use log::info; use sqlx::{Executor, MySql}; use tokens::tokens::user_sessions::UserSessionToken; @@ -22,11 +23,12 @@ INSERT INTO user_sessions ( maybe_creation_type, expires_at ) -VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year ) +VALUES ( ?, ?, ?, ?, NOW() + interval 1 year ) "#, session_token.as_str(), user_token.as_str(), ip_address, + UserWebSessionCreationType::DirectLogin.to_str(), ) .execute(mysql_executor) .await?; diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session_with_transactor.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session_with_transactor.rs index e3ace8999..2647a6624 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session_with_transactor.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_user_session_with_transactor.rs @@ -1,4 +1,5 @@ use anyhow::anyhow; +use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType; use log::info; use sqlx::MySqlPool; @@ -25,11 +26,12 @@ INSERT INTO user_sessions ( maybe_creation_type, expires_at ) -VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year ) +VALUES ( ?, ?, ?, ?, NOW() + interval 1 year ) "#, session_token.as_str(), user_token.as_str(), ip_address, + UserWebSessionCreationType::DirectLogin.to_str(), ); let query_result = transactor.execute(query).await; diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/find_redeemed_session.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/find_redeemed_session.rs index 77bf77d1a..331bc13e0 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/find_redeemed_session.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/find_redeemed_session.rs @@ -1,3 +1,4 @@ +use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType; use sqlx::{Executor, MySql}; use super::bridge_session::BridgeSession; @@ -17,12 +18,13 @@ where 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' +WHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = ? 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 "#, args.session_id, - args.user_token + args.user_token, + UserWebSessionCreationType::DeviceApproval.to_str() ) .fetch_optional(args.mysql_executor) .await diff --git a/crates/schema/database/mysql_testing/src/fixtures/login_challenges.rs b/crates/schema/database/mysql_testing/src/fixtures/login_challenges.rs index db1b08de2..ed65726bb 100644 --- a/crates/schema/database/mysql_testing/src/fixtures/login_challenges.rs +++ b/crates/schema/database/mysql_testing/src/fixtures/login_challenges.rs @@ -1,14 +1,18 @@ //! Mutators/inspection for disposable bridge integration databases only. use sqlx::{MySqlPool, Row}; use chrono::{DateTime, Utc}; +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; +use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType; use enums::by_table::users::user_feature_flag::UserFeatureFlag; +use tokens::tokens::user_login_challenges::UserLoginChallengeToken; use tokens::tokens::users::UserToken; use super::users::{create_test_user, TestUser}; pub struct ChallengeAudit { - pub status: String, - pub failure: Option, + pub status: UserLoginChallengeStatus, + pub failure: Option, pub creation_ip: String, pub failure_ip: Option, pub session_id: Option, @@ -42,7 +46,23 @@ pub async fn audit(pool: &MySqlPool, device_hash: &[u8]) -> ChallengeAudit { } 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") + sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE maybe_creation_type = ?") + .bind(UserWebSessionCreationType::DeviceApproval.to_str()).fetch_one(pool).await.expect("bridge session count") +} + +pub async fn assert_challenge_status_is_explicit(pool: &MySqlPool) { + let column = sqlx::query("SELECT COLUMN_DEFAULT, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_login_challenges' AND COLUMN_NAME = 'status'") + .fetch_one(pool).await.expect("inspect migrated status column"); + assert!(column.get::, _>("COLUMN_DEFAULT").is_none()); + assert_eq!(column.get::("IS_NULLABLE"), "NO"); + + // Every other required field is supplied; MySQL must reject the omitted status. + let token = UserLoginChallengeToken::generate(); + let error = sqlx::query("INSERT INTO user_login_challenges (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, expires_at) VALUES (?, ?, ?, ?, ?, NOW() + INTERVAL 20 MINUTE)") + .bind(token.as_str()).bind(&[0u8; 32][..]).bind(&[1u8; 32][..]) + .bind("BCDFGHJK").bind("192.0.2.1").execute(pool).await.expect_err("status must be explicit"); + let error = error.as_database_error().expect("MySQL missing-column error"); + assert_eq!(error.try_downcast_ref::().unwrap().number(), 1364); } pub async fn set_challenge_deadline(pool: &MySqlPool, device_hash: &[u8], seconds_from_now: i32) { diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/endpoint_integration_tests.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/endpoint_integration_tests.rs index ea79c45ce..199d30925 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/endpoint_integration_tests.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/endpoint_integration_tests.rs @@ -12,6 +12,7 @@ use actix_web::dev::ServerHandle; use actix_web::{web, App, HttpServer}; use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeResponse, LoginChallengeFailure, LoginChallengeResponse, LoginChallengeState, ReviewLoginChallengeResponse}; use enums::by_table::users::user_feature_flag::UserFeatureFlag; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use mysql_testing::fixtures::login_challenges as fixtures; use mysql_testing::fixtures::users::{create_test_user, TestUser}; use mysql_testing::isolated::IsolatedTestDatabase; @@ -44,6 +45,7 @@ struct EndpointHarness { #[cfg_attr(feature = "skip_database_tests", ignore)] async fn approved_passwordless_login_redeems_once_and_authenticates_real_session_endpoint() { let h = EndpointHarness::start().await; + fixtures::assert_challenge_status_is_explicit(&h.db.pool).await; assert_anonymous(h.session(&h.desktop).await); let browser_session = h.session(&h.browser).await; assert_eq!(browser_session["user"]["user_token"], h.user.user_token.as_str()); @@ -51,6 +53,7 @@ async fn approved_passwordless_login_redeems_once_and_authenticates_real_session let created = h.create().await; let hash = security::secret_hash(&created.device_token).unwrap(); let initial = fixtures::audit(&h.db.pool, &hash).await; + assert_eq!(initial.status, UserLoginChallengeStatus::Pending); assert_eq!(initial.lifetime_seconds, 1200); assert_eq!(initial.creation_ip, h.desktop_ip); assert!(initial.deciding_user.is_none()); @@ -114,7 +117,7 @@ async fn approved_passwordless_login_redeems_once_and_authenticates_real_session assert_eq!(session["user"]["can_ban_users"], false); assert!(session.get("signed_session").is_none()); let audit = fixtures::audit(&h.db.pool, &hash).await; - assert_eq!(audit.status, "redeemed"); + assert_eq!(audit.status, UserLoginChallengeStatus::Redeemed); assert_eq!(audit.redemption_ip.as_deref(), Some(h.desktop_ip.as_str())); assert!(audit.session_id.is_some()); assert!(audit.redeemed_at.unwrap() >= audit.decided_at.unwrap()); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs index 56d77b820..c0d05283b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/handlers.rs @@ -2,6 +2,8 @@ use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUser use actix_web::middleware::DefaultHeaders; use actix_web::{web, HttpRequest, HttpResponse}; use artcraft_api_defs::users::login_challenges::*; +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; use mysql_queries::queries::user_login_challenges::create_challenge::{create_challenge, CreateChallengeArgs}; use mysql_queries::queries::user_login_challenges::decide_challenge::{decide_challenge, DecideChallengeArgs}; use mysql_queries::queries::user_login_challenges::expire_challenge::{expire_challenge, ExpireChallengeArgs}; @@ -46,6 +48,7 @@ pub async fn create(request: HttpRequest, _body: web::Json &'static str { } } -fn outcome(challenge: &LoginChallenge, expired: bool) -> Result { +fn outcome(challenge: &LoginChallenge, expired: bool) -> LoginChallengeResponse { // Keep an explicit decline even when its deadline has since elapsed. - let declined = challenge.maybe_failure_type.as_deref() == Some("user_declined"); + let declined = challenge.maybe_failure_type == Some(UserLoginChallengeFailureType::UserDeclined); 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 status = match challenge.status { + UserLoginChallengeStatus::Pending => LoginChallengeState::Pending, + UserLoginChallengeStatus::Approved => LoginChallengeState::Approved, + UserLoginChallengeStatus::Redeemed => LoginChallengeState::Redeemed, + UserLoginChallengeStatus::Failed => LoginChallengeState::Failed, }; - let failure = match challenge.maybe_failure_type.as_deref() { - Some("user_declined") => Some(LoginChallengeFailure::UserDeclined), - Some("expired") => Some(LoginChallengeFailure::Expired), + let failure = match challenge.maybe_failure_type { + Some(UserLoginChallengeFailureType::UserDeclined) => Some(LoginChallengeFailure::UserDeclined), + Some(UserLoginChallengeFailureType::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 }) + LoginChallengeResponse { success: true, status, maybe_failure_type: failure, maybe_signed_session: None } } #[cfg(test)] diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/tests.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/tests.rs index 0ba7c0b5d..5e8ce60a3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/tests.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/tests.rs @@ -12,6 +12,8 @@ use actix_web::{test, web, App, HttpRequest}; use artcraft_api_defs::users::login_challenges::*; use mysql_queries::queries::user_login_challenges::expire_abandoned_challenges::{expire_abandoned_challenges, ExpireAbandonedChallengesArgs}; use mysql_queries::queries::user_login_challenges::lock_by_device::{lock_by_device, LockByDeviceArgs}; +use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType; +use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus; 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}; @@ -122,7 +124,7 @@ async fn approval_mints_once_and_cookie_authenticates_downstream() { 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!(fixtures::audit(&h.db.pool, &hash).await.status, UserLoginChallengeStatus::Redeemed); assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie).to_request()).await.status(), StatusCode::OK); h.db.destroy().await; } @@ -156,7 +158,7 @@ async fn decline_expiry_and_invalid_consent_never_mint_sessions() { 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_eq!(audit.failure, Some(UserLoginChallengeFailureType::UserDeclined)); assert!(audit.session_id.is_none()); for approve_first in [false, true] { @@ -170,7 +172,7 @@ async fn decline_expiry_and_invalid_consent_never_mint_sessions() { 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_eq!(audit.status, UserLoginChallengeStatus::Failed); assert!(audit.failure_ip.is_none()); } assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0); @@ -260,7 +262,7 @@ async fn competing_decisions_are_immutable_and_abandoned_requests_expire() { mysql_executor: &h.db.pool, }).await.unwrap(), 1); let audit = fixtures::audit(&h.db.pool, &hash).await; - assert_eq!(audit.failure.as_deref(), Some("expired")); + assert_eq!(audit.failure, Some(UserLoginChallengeFailureType::Expired)); assert!(audit.failure_ip.is_none()); assert!(audit.session_id.is_none()); } @@ -289,7 +291,7 @@ async fn impersonated_expired_and_revoked_browser_sessions_cannot_approve() { 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::audit(&h.db.pool, &hash).await.status, UserLoginChallengeStatus::Pending); assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0); h.db.destroy().await; }