From 4bdf9bff3a59616b66de38ee3ada77834db399c8 Mon Sep 17 00:00:00 2001 From: Geometrically <18202329+Geometrically@users.noreply.github.com> Date: Tue, 11 Jul 2023 19:13:07 -0700 Subject: [PATCH] 2FA + Add/Remove Auth Providers (#652) * 2FA + Add/Remove Auth Providers * fix fmt issue --- migrations/20230711004131_2fa.sql | 15 + sqlx-data.json | 712 +++++++++++++--------- src/auth/flows.rs | 896 ++++++++++++++++++++-------- src/auth/mod.rs | 4 + src/auth/validate.rs | 63 +- src/database/models/flow_item.rs | 88 +++ src/database/models/ids.rs | 11 - src/database/models/mod.rs | 1 + src/database/models/report_item.rs | 3 +- src/database/models/user_item.rs | 76 ++- src/database/models/version_item.rs | 35 -- src/main.rs | 28 - src/models/threads.rs | 2 +- src/models/users.rs | 24 +- src/routes/v2/project_creation.rs | 4 +- src/routes/v2/projects.rs | 4 +- src/routes/v2/reports.rs | 8 +- src/routes/v2/threads.rs | 35 +- src/routes/v2/users.rs | 116 +++- src/routes/v2/versions.rs | 25 +- 20 files changed, 1483 insertions(+), 667 deletions(-) create mode 100644 migrations/20230711004131_2fa.sql create mode 100644 src/database/models/flow_item.rs diff --git a/migrations/20230711004131_2fa.sql b/migrations/20230711004131_2fa.sql new file mode 100644 index 00000000..22295929 --- /dev/null +++ b/migrations/20230711004131_2fa.sql @@ -0,0 +1,15 @@ +-- Add migration script here +ALTER TABLE states ADD COLUMN user_id bigint references users ON UPDATE CASCADE NULL; + +ALTER TABLE users ADD COLUMN totp_secret varchar(24) null; + +ALTER TABLE users ADD CONSTRAINT email_unique UNIQUE (email); + +DROP TABLE flows; +DROP TABLE states; + +CREATE TABLE user_backup_codes ( + user_id BIGINT NOT NULL REFERENCES users(id), + code BIGINT NOT NULL, + PRIMARY KEY (user_id, code) +); \ No newline at end of file diff --git a/sqlx-data.json b/sqlx-data.json index d35a4122..6d152de5 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -125,38 +125,6 @@ }, "query": "\n INSERT INTO mods_categories (joining_mod_id, joining_category_id, is_additional)\n VALUES ($1, $2, FALSE)\n " }, - "0640761c8c14cfbcd7009bd50185f39fd7f1467ea81ae3d1a2477887075d72d5": { - "describe": { - "columns": [ - { - "name": "url", - "ordinal": 0, - "type_info": "Varchar" - }, - { - "name": "expires", - "ordinal": 1, - "type_info": "Timestamptz" - }, - { - "name": "provider", - "ordinal": 2, - "type_info": "Varchar" - } - ], - "nullable": [ - false, - false, - false - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n SELECT url, expires, provider FROM states\n WHERE id = $1\n " - }, "06a92b638c77276f36185788748191e7731a2cce874ecca4af913d0d0412d223": { "describe": { "columns": [], @@ -825,26 +793,6 @@ }, "query": "\n UPDATE users\n SET avatar_url = $1\n WHERE (id = $2)\n " }, - "1c7b0eb4341af5a7942e52f632cf582561f10b4b6a41a082fb8a60f04ac17c6e": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "SELECT EXISTS(SELECT 1 FROM states WHERE id=$1)" - }, "1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7": { "describe": { "columns": [ @@ -1065,6 +1013,19 @@ }, "query": "\n SELECT m.id id, tm.user_id user_id, tm.payouts_split payouts_split\n FROM mods m\n INNER JOIN team_members tm on m.team_id = tm.team_id AND tm.accepted = TRUE\n WHERE m.id = ANY($1)\n " }, + "282190042b5ccc85e9b1643072a8703edae76dbbcc9493d5c9db392dca60230d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + } + }, + "query": "\n UPDATE users\n SET google_id = $2\n WHERE (id = $1)\n " + }, "294f264382ad55475b51776cd5d306c4867e8e6966ab79921bba69dc023f8337": { "describe": { "columns": [], @@ -1161,20 +1122,6 @@ }, "query": "\n DELETE FROM team_members\n WHERE user_id = $1\n " }, - "2ce3c13256d774b389ce2296c8f3d2b0e9d764b6ead010d37616bd8eec8b647e": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Varchar", - "Varchar" - ] - } - }, - "query": "\n INSERT INTO states (id, url, provider)\n VALUES ($1, $2, $3)\n " - }, "2d460f25461e95c744c835af5d67f8a7dd2438a46e3033611dfc0edd74fb9180": { "describe": { "columns": [ @@ -1196,6 +1143,26 @@ }, "query": "\n SELECT COUNT(v.id)\n FROM versions v\n INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)\n WHERE v.status = ANY($2)\n " }, + "2d68489b978c7a19bbea6a9736d23ca253f4038c0e3e060720d669825073b242": { + "describe": { + "columns": [ + { + "name": "code", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT code FROM user_backup_codes\n WHERE user_id = $1\n " + }, "2e14706127d9822d5a0d7ada02425d224805637d03eda1343e12111f7deba443": { "describe": { "columns": [], @@ -1234,32 +1201,6 @@ }, "query": "\n INSERT INTO users (\n id, username, name, email,\n avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15\n )\n " }, - "2eeb8e6fe76c13bcab19ec983234d6fc10a57ea4452740c01504ea4443c18b83": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "password", - "ordinal": 1, - "type_info": "Text" - } - ], - "nullable": [ - false, - true - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT id, password FROM users\n WHERE email = $1\n " - }, "2f4a620f954c7488e8bdb94a3d6968cec6d1332942b9e9f60925d14a8c2040f7": { "describe": { "columns": [ @@ -1281,6 +1222,38 @@ }, "query": "\n SELECT m.id FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id\n WHERE tm.user_id = $1 AND tm.role = $2\n " }, + "304aaf99f8909f8315b57fb42b4320de66e7abb2fe1e7bdd19d8c4fd7d5b06be": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT id FROM users\n WHERE email = $1\n " + }, + "31415c0678f9f968181d1b1ee83e0ded54a99afc62a643dce73636f9bb20fd57": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET microsoft_id = NULL\n WHERE (id = $1)\n " + }, "320d73cd900a6e00f0e74b7a8c34a7658d16034b01a35558cb42fa9c16185eb5": { "describe": { "columns": [ @@ -1325,6 +1298,18 @@ }, "query": "\n SELECT tm.id, tm.author_id, tm.thread_id, tm.body, tm.created\n FROM threads_messages tm\n WHERE tm.id = ANY($1)\n " }, + "332f1d23442b4a637d4bccf29363a7aa4da974a1b6c5752eb1b611da75030741": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM pats\n WHERE user_id = $1\n " + }, "33a965c7dc615d3b701c05299889357db8dd36d378850625d2602ba471af4885": { "describe": { "columns": [], @@ -1390,6 +1375,18 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)" }, + "36d4b7a18f35cd177cec15e64d3cf4c156980167f0629af37c3947e5ed317faa": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET discord_id = NULL\n WHERE (id = $1)\n " + }, "371048e45dd74c855b84cdb8a6a565ccbef5ad166ec9511ab20621c336446da6": { "describe": { "columns": [], @@ -1804,6 +1801,18 @@ }, "query": "\n INSERT INTO threads (\n id, thread_type, mod_id, report_id\n )\n VALUES (\n $1, $2, $3, $4\n )\n " }, + "51e53fa0cc848654300067d4f598da49a16f5ce3aa046d1b08628566b80ce88f": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n " + }, "5295fba2053675c8414c0b37a59943535b9a438a642ea1c68045e987f05ade13": { "describe": { "columns": [ @@ -1840,6 +1849,18 @@ }, "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n GROUP BY l.id;\n " }, + "52d947ff389e17378ff6d978916a85c2d6e7ef3cd4f09f4d5f070a6c33619cd9": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n " + }, "53a8966ac345cc334ad65ea907be81af74e90b1217696c7eedcf8a8e3fca736e": { "describe": { "columns": [], @@ -2223,6 +2244,18 @@ }, "query": "\n UPDATE mods_gallery\n SET ordering = $2\n WHERE id = $1\n " }, + "5f614c000f1a83ec87ebd0d514c9b484554dc0d097c6e105c42717930fd58058": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET steam_id = NULL\n WHERE (id = $1)\n " + }, "5f94e9e767ec4be7f9136b991b4a29373dbe48feb2f61281e3212721095ed675": { "describe": { "columns": [], @@ -2239,6 +2272,153 @@ }, "query": "\n INSERT INTO dependencies (dependent_id, dependency_type, dependency_id, mod_dependency_id, dependency_file_name)\n VALUES ($1, $2, $3, $4, $5)\n " }, + "60a251aea1efbc7d9357255e520f0ac13f3697fecb84b1e9edd5d9ea61fe0cb0": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "email", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "avatar_url", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "bio", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "created", + "ordinal": 6, + "type_info": "Timestamptz" + }, + { + "name": "role", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "badges", + "ordinal": 8, + "type_info": "Int8" + }, + { + "name": "balance", + "ordinal": 9, + "type_info": "Numeric" + }, + { + "name": "payout_wallet", + "ordinal": 10, + "type_info": "Varchar" + }, + { + "name": "payout_wallet_type", + "ordinal": 11, + "type_info": "Varchar" + }, + { + "name": "payout_address", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "github_id", + "ordinal": 13, + "type_info": "Int8" + }, + { + "name": "discord_id", + "ordinal": 14, + "type_info": "Int8" + }, + { + "name": "gitlab_id", + "ordinal": 15, + "type_info": "Int8" + }, + { + "name": "google_id", + "ordinal": 16, + "type_info": "Varchar" + }, + { + "name": "steam_id", + "ordinal": 17, + "type_info": "Int8" + }, + { + "name": "microsoft_id", + "ordinal": 18, + "type_info": "Varchar" + }, + { + "name": "email_verified", + "ordinal": 19, + "type_info": "Bool" + }, + { + "name": "password", + "ordinal": 20, + "type_info": "Text" + }, + { + "name": "totp_secret", + "ordinal": 21, + "type_info": "Varchar" + } + ], + "nullable": [ + false, + true, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + } + }, + "query": "\n SELECT id, name, email,\n avatar_url, username, bio,\n created, role, badges,\n balance, payout_wallet, payout_wallet_type, payout_address,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n " + }, "61a7f29e024bf2f1368370e3f6e8ef70317c7e8545b5b6d4235f21164948ba27": { "describe": { "columns": [], @@ -2252,6 +2432,19 @@ }, "query": "\n UPDATE mods_gallery\n SET featured = $2\n WHERE mod_id = $1\n " }, + "6289dead78b0e128603f996be47e2d2a487668417124c48828eba8e01e18e651": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET discord_id = $2\n WHERE (id = $1)\n " + }, "64d5e7cfb8472fbedcd06143db0db2f4c9677c42f73c540e85ccb5aee1a7b6f9": { "describe": { "columns": [], @@ -2349,6 +2542,18 @@ }, "query": "\n DELETE FROM notifications\n WHERE user_id = $1\n " }, + "69b093cad9109ccf4779bfd969897f6b9ebc9d0d4230c958de4fa07435776349": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM sessions\n WHERE user_id = $1\n " + }, "6a7b7704c2a0c52a70f5d881a1e6d3e8e77ddaa83ecc5688cd86bf327775fb76": { "describe": { "columns": [ @@ -2511,6 +2716,18 @@ }, "query": "\n SELECT hp.created, hp.amount, hp.status\n FROM historical_payouts hp\n WHERE hp.user_id = $1\n ORDER BY hp.created DESC\n " }, + "6d2f27f02fe1073153a95ed2f7431a19170c9641588b444e45c94d0c6ba5b52c": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET github_id = NULL\n WHERE (id = $1)\n " + }, "6d883ea05aead20f571a0f63bfd63f1d432717ec7a0fb9ab29e01fcb061b3afc": { "describe": { "columns": [], @@ -2523,6 +2740,19 @@ }, "query": "\n UPDATE files\n SET is_primary = FALSE\n WHERE (version_id = $1)\n " }, + "6db583cb1c69cffeefaacc728046d62cc7d43018ced98420f9f629145f65f81a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET steam_id = $2\n WHERE (id = $1)\n " + }, "6db607d629be3047d53ff92bb82c07700595e8f4fcb7b602918540af4ae50d8b": { "describe": { "columns": [], @@ -2597,6 +2827,19 @@ }, "query": "\n INSERT INTO threads_members (\n thread_id, user_id\n )\n VALUES (\n $1, $2\n )\n " }, + "71650a60acac690130e53c347c5882ead19c9b071f72de45e8737dc077c593d6": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET gitlab_id = $2\n WHERE (id = $1)\n " + }, "71abd207410d123f9a50345ddcddee335fea0d0cc6f28762713ee01a36aee8a0": { "describe": { "columns": [ @@ -2762,6 +3005,18 @@ }, "query": "\n SELECT id, short, name FROM donation_platforms\n " }, + "7711b7c651015510a101cc409fa6f5229ac93d7209df8bc158f4dd4442f611f2": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n " + }, "78699c6d2ca0f13f4609310df479903e8d5e0d2d4c2603df0333be7dc040a4ee": { "describe": { "columns": [], @@ -3292,147 +3547,6 @@ }, "query": "\n SELECT t.id, t.thread_type, t.mod_id, t.report_id, t.show_in_mod_inbox,\n ARRAY_AGG(DISTINCT tm.user_id) filter (where tm.user_id is not null) members,\n JSONB_AGG(DISTINCT jsonb_build_object('id', tmsg.id, 'author_id', tmsg.author_id, 'thread_id', tmsg.thread_id, 'body', tmsg.body, 'created', tmsg.created)) filter (where tmsg.id is not null) messages\n FROM threads t\n LEFT OUTER JOIN threads_messages tmsg ON tmsg.thread_id = t.id\n LEFT OUTER JOIN threads_members tm ON tm.thread_id = t.id\n WHERE t.id = ANY($1)\n GROUP BY t.id\n " }, - "95c131d3ea36d53f9dccc6ff8bb7efd3fb571e4175857178c24f5c841a1ec7ed": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "email", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "avatar_url", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "bio", - "ordinal": 5, - "type_info": "Varchar" - }, - { - "name": "created", - "ordinal": 6, - "type_info": "Timestamptz" - }, - { - "name": "role", - "ordinal": 7, - "type_info": "Varchar" - }, - { - "name": "badges", - "ordinal": 8, - "type_info": "Int8" - }, - { - "name": "balance", - "ordinal": 9, - "type_info": "Numeric" - }, - { - "name": "payout_wallet", - "ordinal": 10, - "type_info": "Varchar" - }, - { - "name": "payout_wallet_type", - "ordinal": 11, - "type_info": "Varchar" - }, - { - "name": "payout_address", - "ordinal": 12, - "type_info": "Varchar" - }, - { - "name": "github_id", - "ordinal": 13, - "type_info": "Int8" - }, - { - "name": "discord_id", - "ordinal": 14, - "type_info": "Int8" - }, - { - "name": "gitlab_id", - "ordinal": 15, - "type_info": "Int8" - }, - { - "name": "google_id", - "ordinal": 16, - "type_info": "Varchar" - }, - { - "name": "steam_id", - "ordinal": 17, - "type_info": "Int8" - }, - { - "name": "microsoft_id", - "ordinal": 18, - "type_info": "Varchar" - }, - { - "name": "email_verified", - "ordinal": 19, - "type_info": "Bool" - }, - { - "name": "password", - "ordinal": 20, - "type_info": "Text" - } - ], - "nullable": [ - false, - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - false, - true - ], - "parameters": { - "Left": [ - "Int8Array", - "TextArray" - ] - } - }, - "query": "\n SELECT id, name, email,\n avatar_url, username, bio,\n created, role, badges,\n balance, payout_wallet, payout_wallet_type, payout_address,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n " - }, "95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350": { "describe": { "columns": [], @@ -3505,6 +3619,18 @@ }, "query": "\n UPDATE mods\n SET wiki_url = $1\n WHERE (id = $2)\n " }, + "99e7779380ebae726051ba8e2810f37bee36f3fb36729c07ef11d0ac1b611d7e": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET totp_secret = NULL\n WHERE (id = $1)\n " + }, "9aab2350d576fd934b0541d1f71f320ac939b44a179fee3d1638113cdb3ddfe7": { "describe": { "columns": [], @@ -3519,6 +3645,19 @@ }, "query": "\n INSERT INTO mods_donations (joining_mod_id, joining_platform_id, url)\n VALUES ($1, $2, $3)\n " }, + "9bf8862af8f636c4ef77e8c9f1f5d31d4f2d3f5b73fb6e6ca8a09ad5224250c3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET totp_secret = $1\n WHERE (id = $2)\n " + }, "9c8f3f9503b5bb52e05bbc8a8eee7f640ab7d6b04a59ec111ce8b23e886911de": { "describe": { "columns": [], @@ -3562,6 +3701,18 @@ }, "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = FALSE\n " }, + "a04c2455d4ad53e2559927f1783322196c2e067666716407da737a7a44b23e66": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET google_id = NULL\n WHERE (id = $1)\n " + }, "a0c91184d5a02b986decac3c34e78b61451ff90e103bcf1ec46f8da3bbcc1ff2": { "describe": { "columns": [], @@ -3684,18 +3835,6 @@ }, "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.accepted, tm.payouts_split, tm.ordering FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE\n WHERE m.id = $1\n " }, - "a39ce28b656032f862b205cffa393a76b989f4803654a615477a94fda5f57354": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM states\n WHERE id = $1\n " - }, "a3e27b758ca441fa82f6bcd42915b92fb23a7db19a7eb27db7ed92eeba4b566e": { "describe": { "columns": [ @@ -3742,6 +3881,19 @@ }, "query": "\n UPDATE mods\n SET status = $1, approved = $2\n WHERE (id = $3)\n " }, + "a48b717b74531dc457069ee811ec1adc1da195f00a42fff7f08667b139cd8fea": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n INSERT INTO user_backup_codes (\n user_id, code\n )\n VALUES (\n $1, $2\n )\n " + }, "a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39": { "describe": { "columns": [ @@ -3858,15 +4010,17 @@ }, "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status,\n JSONB_AGG(DISTINCT jsonb_build_object('version', gv.version, 'created', gv.created)) filter (where gv.version is not null) game_versions,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies\n FROM versions v\n LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id\n LEFT OUTER JOIN game_versions gv on gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n " }, - "a90bb6904e1b790c0e29e060dac5ba4c2a6087e07c1197dc1f59f0aff31944c9": { + "aa1d5c33e4ef370e372c6ea467cef103e494bbd167e8d1608ebd6786b87d4129": { "describe": { "columns": [], "nullable": [], "parameters": { - "Left": [] + "Left": [ + "Int8" + ] } }, - "query": "\n DELETE FROM states\n WHERE expires < CURRENT_DATE\n " + "query": "\n UPDATE users\n SET gitlab_id = NULL\n WHERE (id = $1)\n " }, "aaec611bae08eac41c163367dc508208178170de91165095405f1b41e47f5e7f": { "describe": { @@ -4225,29 +4379,6 @@ }, "query": "\n UPDATE mods\n SET license_url = $1\n WHERE (id = $2)\n " }, - "c15ec51ec0e9900e5569557a618760cb4bbb303f0f9ca1189f18557e67d18b56": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Int8", - "Text", - "Int8", - "Text" - ] - } - }, - "query": "\n SELECT v.id FROM versions v\n INNER JOIN mods m ON mod_id = m.id\n WHERE (m.id = $1 OR m.slug = $2) AND (v.id = $3 OR v.version_number = $4)\n ORDER BY date_published ASC\n " - }, "c1a3f6dcef6110d6ea884670fb82bac14b98e922bb5673c048ccce7b7300539b": { "describe": { "columns": [ @@ -5349,6 +5480,32 @@ }, "query": "\n SELECT v.id id, v.mod_id mod_id, file_type FROM files f\n INNER JOIN versions v ON v.id = f.version_id\n WHERE f.url = $1\n " }, + "de1bf7e33a99a10154cefdbe3b8322e4c6a19448b6ee3c6087b1b8163bc52cb1": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1 AND code = $2\n " + }, + "de644eab84656a7e2b0d3108580763ebcd149cd6256d474521423d3e6ffaae65": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + } + }, + "query": "\n UPDATE users\n SET microsoft_id = $2\n WHERE (id = $1)\n " + }, "debb47a2718f79684c8776da7f289b8d178c302bb5a69562b963b8d008973b8d": { "describe": { "columns": [], @@ -5362,6 +5519,19 @@ }, "query": "\n UPDATE threads_messages\n SET body = '{\"type\": \"deleted\"}', author_id = $2\n WHERE author_id = $1\n " }, + "df816c410a7bd4c0616ed24436bfc84308477b6d40de0b70adbc0c723a1f5cea": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET github_id = $2\n WHERE (id = $1)\n " + }, "df871bd959ba97f105ac575f34d8d2a39cbc44a07e0339750a0e477e6fd582ed": { "describe": { "columns": [], diff --git a/src/auth/flows.rs b/src/auth/flows.rs index 2429a3c0..d7fd8da2 100644 --- a/src/auth/flows.rs +++ b/src/auth/flows.rs @@ -1,19 +1,23 @@ use crate::auth::session::issue_session; -use crate::auth::AuthenticationError; -use crate::database::models::{generate_state_id, StateId}; +use crate::auth::validate::get_user_record_from_bearer_token; +use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::flow_item::Flow; use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::{parse_base62, to_base62}; +use crate::models::ids::random_base62_rng; +use crate::models::pats::Scopes; use crate::models::users::{Badges, Role}; use crate::parse_strings_from_var; +use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::captcha::check_turnstile_captcha; use crate::util::ext::{get_image_content_type, get_image_ext}; use crate::util::validate::{validation_errors_to_string, RE_URL_SAFE}; use actix_web::web::{scope, Data, Query, ServiceConfig}; -use actix_web::{get, post, web, HttpRequest, HttpResponse}; +use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use chrono::Utc; +use chrono::{Duration, Utc}; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; use reqwest::header::AUTHORIZATION; @@ -27,10 +31,14 @@ use validator::Validate; pub fn config(cfg: &mut ServiceConfig) { cfg.service(scope("auth").service(auth_callback).service(init)) .service(create_account_with_password) - .service(login_password); + .service(login_password) + .service(login_2fa) + .service(begin_2fa_flow) + .service(finish_2fa_flow) + .service(remove_2fa); } -#[derive(Serialize, Deserialize, Default, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Default, Eq, PartialEq, Clone, Copy)] #[serde(rename_all = "lowercase")] pub enum AuthProvider { #[default] @@ -54,8 +62,7 @@ pub struct TempUser { } impl AuthProvider { - pub fn get_redirect_url(&self, state: StateId) -> Result { - let state = to_base62(state.0 as u64); + pub fn get_redirect_url(&self, state: String) -> Result { let self_addr = dotenvy::var("SELF_ADDR")?; let raw_redirect_uri = format!("{}/v2/auth/callback", self_addr); let redirect_uri = urlencoding::encode(&raw_redirect_uri); @@ -600,35 +607,6 @@ impl AuthProvider { } }) } - - pub fn as_str(&self) -> &'static str { - match self { - AuthProvider::GitHub => "github", - AuthProvider::Discord => "discord", - AuthProvider::Microsoft => "microsoft", - AuthProvider::GitLab => "gitlab", - AuthProvider::Google => "google", - AuthProvider::Steam => "steam", - } - } - - pub fn from_str(string: &str) -> AuthProvider { - match string { - "github" => AuthProvider::GitHub, - "discord" => AuthProvider::Discord, - "microsoft" => AuthProvider::Microsoft, - "gitlab" => AuthProvider::GitLab, - "google" => AuthProvider::Google, - "steam" => AuthProvider::Steam, - _ => AuthProvider::GitHub, - } - } -} - -impl std::fmt::Display for AuthProvider { - fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - fmt.write_str(self.as_str()) - } } #[derive(Serialize, Deserialize)] @@ -636,6 +614,7 @@ pub struct AuthorizationInit { pub url: String, #[serde(default)] pub provider: AuthProvider, + pub token: Option, } #[derive(Serialize, Deserialize)] pub struct Authorization { @@ -647,8 +626,11 @@ pub struct Authorization { // http://localhost:8000/auth/init?url=https://modrinth.com #[get("init")] pub async fn init( + req: HttpRequest, Query(info): Query, // callback url client: Data, + redis: Data, + session_queue: Data, ) -> Result { let url = url::Url::parse(&info.url).map_err(|_| AuthenticationError::Url)?; @@ -658,24 +640,30 @@ pub async fn init( return Err(AuthenticationError::Url); } - let mut transaction = client.begin().await?; + let user_id = if let Some(token) = info.token { + let (_, user) = get_user_record_from_bearer_token( + &req, + Some(&token), + &**client, + &redis, + &session_queue, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - let state = generate_state_id(&mut transaction).await?; + Some(user.id) + } else { + None + }; - sqlx::query!( - " - INSERT INTO states (id, url, provider) - VALUES ($1, $2, $3) - ", - state.0, - info.url, - info.provider.to_string() - ) - .execute(&mut *transaction) + let state = Flow::OAuth { + user_id, + url: info.url, + provider: info.provider, + } + .insert(Utc::now() + Duration::minutes(30), &redis) .await?; - transaction.commit().await?; - let url = info.provider.get_redirect_url(state)?; Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*url)) @@ -690,208 +678,314 @@ pub async fn auth_callback( file_host: Data>, redis: Data, ) -> Result { - let mut transaction = client.begin().await?; - let state = query .get("state") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - let state_id: u64 = parse_base62(state)?; - let result_option = sqlx::query!( - " - SELECT url, expires, provider FROM states - WHERE id = $1 - ", - state_id as i64 - ) - .fetch_optional(&mut *transaction) - .await?; + let flow = Flow::get(state, &redis).await?; // Extract cookie header from request - if let Some(result) = result_option { - // Extract cookie header to get authenticated user from Minos - let duration: chrono::Duration = result.expires - Utc::now(); - if duration.num_seconds() < 0 { - return Err(AuthenticationError::InvalidCredentials); - } - sqlx::query!( - " - DELETE FROM states - WHERE id = $1 - ", - state_id as i64 - ) - .execute(&mut *transaction) - .await?; - - let provider = AuthProvider::from_str(&result.provider); + if let Some(Flow::OAuth { + user_id, + provider, + url, + }) = flow + { + Flow::remove(state, &redis).await?; let token = provider.get_token(query).await?; let oauth_user = provider.get_user(&token).await?; - let user_id = if let Some(user_id) = provider - .get_user_id(&oauth_user.id, &mut *transaction) - .await? - { - user_id - } else { - let user_id = crate::database::models::generate_user_id(&mut transaction).await?; - let mut username_increment: i32 = 0; - let mut username = None; + let user_id_opt = provider.get_user_id(&oauth_user.id, &**client).await?; - while username.is_none() { - let test_username = format!( - "{}{}", - oauth_user.username, - if username_increment > 0 { - username_increment.to_string() - } else { - "".to_string() - } - ); + let mut transaction = client.begin().await?; + if let Some(id) = user_id { + if user_id_opt.is_some() { + return Err(AuthenticationError::DuplicateUser); + } - let new_id = - crate::database::models::User::get(&test_username, &**client, &redis).await?; - - if new_id.is_none() { - username = Some(test_username); - } else { - username_increment += 1; + match provider { + AuthProvider::GitHub => { + sqlx::query!( + " + UPDATE users + SET github_id = $2 + WHERE (id = $1) + ", + id as crate::database::models::UserId, + oauth_user.id.parse::().ok(), + ) + .execute(&mut *transaction) + .await?; + } + AuthProvider::Discord => { + sqlx::query!( + " + UPDATE users + SET discord_id = $2 + WHERE (id = $1) + ", + id as crate::database::models::UserId, + oauth_user.id.parse::().ok(), + ) + .execute(&mut *transaction) + .await?; + } + AuthProvider::Microsoft => { + sqlx::query!( + " + UPDATE users + SET microsoft_id = $2 + WHERE (id = $1) + ", + id as crate::database::models::UserId, + oauth_user.id, + ) + .execute(&mut *transaction) + .await?; + } + AuthProvider::GitLab => { + sqlx::query!( + " + UPDATE users + SET gitlab_id = $2 + WHERE (id = $1) + ", + id as crate::database::models::UserId, + oauth_user.id.parse::().ok(), + ) + .execute(&mut *transaction) + .await?; + } + AuthProvider::Google => { + sqlx::query!( + " + UPDATE users + SET google_id = $2 + WHERE (id = $1) + ", + id as crate::database::models::UserId, + oauth_user.id, + ) + .execute(&mut *transaction) + .await?; + } + AuthProvider::Steam => { + sqlx::query!( + " + UPDATE users + SET steam_id = $2 + WHERE (id = $1) + ", + id as crate::database::models::UserId, + oauth_user.id.parse::().ok(), + ) + .execute(&mut *transaction) + .await?; } } - let avatar_url = if let Some(avatar_url) = oauth_user.avatar_url { - let cdn_url = dotenvy::var("CDN_URL")?; + crate::database::models::User::clear_caches(&[(id, None)], &redis).await?; + transaction.commit().await?; - let res = reqwest::get(&avatar_url).await?; - let headers = res.headers().clone(); + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*url)) + .json(serde_json::json!({ "url": url }))) + } else { + let user_id = if let Some(user_id) = user_id_opt { + let user = crate::database::models::User::get_id(user_id, &**client, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - let img_data = if let Some(content_type) = headers - .get(reqwest::header::CONTENT_TYPE) - .and_then(|ct| ct.to_str().ok()) - { - get_image_ext(content_type).map(|ext| (ext, content_type)) - } else if let Some(ext) = avatar_url.rsplit('.').next() { - get_image_content_type(ext).map(|content_type| (ext, content_type)) + if user.totp_secret.is_some() { + let flow = Flow::Login2FA { user_id: user.id } + .insert(Utc::now() + Duration::minutes(30), &redis) + .await?; + + let redirect_url = format!( + "{}{}error=2fa_required&flow={}", + url, + if url.contains('?') { "&" } else { "?" }, + flow + ); + + return Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*redirect_url)) + .json(serde_json::json!({ "url": redirect_url }))); + } + + user_id + } else { + if let Some(email) = &oauth_user.email { + if crate::database::models::User::get_email(email, &**client) + .await? + .is_some() + { + return Err(AuthenticationError::DuplicateUser); + } + } + + let user_id = crate::database::models::generate_user_id(&mut transaction).await?; + + let mut username_increment: i32 = 0; + let mut username = None; + + while username.is_none() { + let test_username = format!( + "{}{}", + oauth_user.username, + if username_increment > 0 { + username_increment.to_string() + } else { + "".to_string() + } + ); + + let new_id = + crate::database::models::User::get(&test_username, &**client, &redis) + .await?; + + if new_id.is_none() { + username = Some(test_username); + } else { + username_increment += 1; + } + } + + let avatar_url = if let Some(avatar_url) = oauth_user.avatar_url { + let cdn_url = dotenvy::var("CDN_URL")?; + + let res = reqwest::get(&avatar_url).await?; + let headers = res.headers().clone(); + + let img_data = if let Some(content_type) = headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + { + get_image_ext(content_type).map(|ext| (ext, content_type)) + } else if let Some(ext) = avatar_url.rsplit('.').next() { + get_image_content_type(ext).map(|content_type| (ext, content_type)) + } else { + None + }; + + if let Some((ext, content_type)) = img_data { + let bytes = res.bytes().await?; + let hash = sha1::Sha1::from(&bytes).hexdigest(); + + let upload_data = file_host + .upload_file( + content_type, + &format!( + "user/{}/{}.{}", + crate::models::users::UserId::from(user_id), + hash, + ext + ), + bytes, + ) + .await?; + + Some(format!("{}/{}", cdn_url, upload_data.file_name)) + } else { + None + } } else { None }; - if let Some((ext, content_type)) = img_data { - let bytes = res.bytes().await?; - let hash = sha1::Sha1::from(&bytes).hexdigest(); + if let Some(username) = username { + crate::database::models::User { + id: user_id, + github_id: if provider == AuthProvider::GitHub { + Some( + oauth_user + .id + .clone() + .parse() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + } else { + None + }, + discord_id: if provider == AuthProvider::Discord { + Some( + oauth_user + .id + .parse() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + } else { + None + }, + gitlab_id: if provider == AuthProvider::GitLab { + Some( + oauth_user + .id + .parse() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + } else { + None + }, + google_id: if provider == AuthProvider::Google { + Some(oauth_user.id.clone()) + } else { + None + }, + steam_id: if provider == AuthProvider::Steam { + Some( + oauth_user + .id + .parse() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + } else { + None + }, + microsoft_id: if provider == AuthProvider::Microsoft { + Some(oauth_user.id) + } else { + None + }, + password: None, + totp_secret: None, + username, + name: oauth_user.name, + email: oauth_user.email, + email_verified: true, + avatar_url, + bio: oauth_user.bio, + created: Utc::now(), + role: Role::Developer.to_string(), + badges: Badges::default(), + balance: Decimal::ZERO, + payout_wallet: None, + payout_wallet_type: None, + payout_address: None, + } + .insert(&mut transaction) + .await?; - let upload_data = file_host - .upload_file( - content_type, - &format!( - "user/{}/{}.{}", - crate::models::users::UserId::from(user_id), - hash, - ext - ), - bytes, - ) - .await?; - - Some(format!("{}/{}", cdn_url, upload_data.file_name)) + user_id } else { - None + return Err(AuthenticationError::InvalidCredentials); } - } else { - None }; - if let Some(username) = username { - crate::database::models::User { - id: user_id, - github_id: if provider == AuthProvider::GitHub { - Some( - oauth_user - .id - .clone() - .parse() - .map_err(|_| AuthenticationError::InvalidCredentials)?, - ) - } else { - None - }, - discord_id: if provider == AuthProvider::Discord { - Some( - oauth_user - .id - .parse() - .map_err(|_| AuthenticationError::InvalidCredentials)?, - ) - } else { - None - }, - gitlab_id: if provider == AuthProvider::GitLab { - Some( - oauth_user - .id - .parse() - .map_err(|_| AuthenticationError::InvalidCredentials)?, - ) - } else { - None - }, - google_id: if provider == AuthProvider::Google { - Some(oauth_user.id.clone()) - } else { - None - }, - steam_id: if provider == AuthProvider::Steam { - Some( - oauth_user - .id - .parse() - .map_err(|_| AuthenticationError::InvalidCredentials)?, - ) - } else { - None - }, - microsoft_id: if provider == AuthProvider::Microsoft { - Some(oauth_user.id) - } else { - None - }, - password: None, - username, - name: oauth_user.name, - email: oauth_user.email, - email_verified: true, - avatar_url, - bio: oauth_user.bio, - created: Utc::now(), - role: Role::Developer.to_string(), - badges: Badges::default(), - balance: Decimal::ZERO, - payout_wallet: None, - payout_wallet_type: None, - payout_address: None, - } - .insert(&mut transaction) - .await?; + let session = issue_session(req, user_id, &mut transaction, &redis).await?; + transaction.commit().await?; - user_id + let redirect_url = if url.contains('?') { + format!("{}&code={}", url, session.session) } else { - return Err(AuthenticationError::InvalidCredentials); - } - }; + format!("{}?code={}", url, session.session) + }; - let session = issue_session(req, user_id, &mut transaction, &redis).await?; - transaction.commit().await?; - - let redirect_url = if result.url.contains('?') { - format!("{}&code={}", result.url, session.session) - } else { - format!("{}?code={}", result.url, session.session) - }; - - Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*redirect_url)) - .json(serde_json::json!({ "url": redirect_url }))) + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*redirect_url)) + .json(serde_json::json!({ "url": redirect_url }))) + } } else { Err(AuthenticationError::InvalidCredentials) } @@ -957,6 +1051,15 @@ pub async fn create_account_with_password( .hash_password(new_account.password.as_bytes(), &salt)? .to_string(); + if crate::database::models::User::get_email(&new_account.email, &**pool) + .await? + .is_some() + { + return Err(ApiError::InvalidInput( + "Email is already registered on Modrinth!".to_string(), + )); + } + crate::database::models::User { id: user_id, github_id: None, @@ -966,6 +1069,7 @@ pub async fn create_account_with_password( steam_id: None, microsoft_id: None, password: Some(password_hash), + totp_secret: None, username: new_account.username.clone(), name: Some(new_account.username), email: Some(new_account.email), @@ -1008,43 +1112,335 @@ pub async fn login_password( return Err(ApiError::Turnstile); } - let (user_id, password) = if let Some(user) = + let user = if let Some(user) = crate::database::models::User::get(&login.username, &**pool, &redis).await? { - ( - user.id, - user.password - .ok_or_else(|| AuthenticationError::InvalidCredentials)?, - ) + user } else { - let user_pass = sqlx::query!( - " - SELECT id, password FROM users - WHERE email = $1 - ", - login.username - ) - .fetch_one(&**pool) - .await - .map_err(|_| AuthenticationError::InvalidCredentials)?; + let user = crate::database::models::User::get_email(&login.username, &**pool) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - ( - crate::database::models::UserId(user_pass.id), - user_pass - .password - .ok_or_else(|| AuthenticationError::InvalidCredentials)?, - ) + crate::database::models::User::get_id(user, &**pool, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)? }; let hasher = Argon2::default(); hasher - .verify_password(login.password.as_bytes(), &PasswordHash::new(&password)?) + .verify_password( + login.password.as_bytes(), + &PasswordHash::new( + &user + .password + .ok_or_else(|| AuthenticationError::InvalidCredentials)?, + )?, + ) .map_err(|_| AuthenticationError::InvalidCredentials)?; + if user.totp_secret.is_some() { + let flow = Flow::Login2FA { user_id: user.id } + .insert(Utc::now() + Duration::minutes(30), &redis) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "error": "2fa_required", + "description": "2FA is required to complete this operation.", + "flow": flow, + }))) + } else { + let mut transaction = pool.begin().await?; + let session = issue_session(req, user.id, &mut transaction, &redis).await?; + let res = crate::models::sessions::Session::from(session, true); + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(res)) + } +} + +#[derive(Deserialize, Validate)] +pub struct Login2FA { + pub code: String, + pub flow: String, +} + +fn get_2fa_code(secret: String) -> Result { + let totp = totp_rs::TOTP::new( + totp_rs::Algorithm::SHA1, + 6, + 1, + 30, + totp_rs::Secret::Encoded(secret) + .to_bytes() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + .map_err(|_| AuthenticationError::InvalidCredentials)?; + let token = totp + .generate_current() + .map_err(|_| AuthenticationError::InvalidCredentials)?; + + Ok(token) +} + +#[post("login/2fa")] +pub async fn login_2fa( + req: HttpRequest, + pool: Data, + redis: Data, + login: web::Json, +) -> Result { + let flow = Flow::get(&login.flow, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if let Flow::Login2FA { user_id } = flow { + let user = crate::database::models::User::get_id(user_id, &**pool, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + let token = get_2fa_code( + user.totp_secret + .ok_or_else(|| AuthenticationError::InvalidCredentials)?, + )?; + + let mut transaction = pool.begin().await?; + if token != login.code { + let backup_codes = + crate::database::models::User::get_backup_codes(user_id, &**pool).await?; + + if !backup_codes.contains(&login.code) { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } else { + let code = parse_base62(&login.code).unwrap_or_default(); + + sqlx::query!( + " + DELETE FROM user_backup_codes + WHERE user_id = $1 AND code = $2 + ", + user_id as crate::database::models::ids::UserId, + code as i64, + ) + .execute(&mut *transaction) + .await?; + + crate::database::models::User::clear_caches(&[(user_id, None)], &redis).await?; + } + } + Flow::remove(&login.flow, &redis).await?; + + let session = issue_session(req, user_id, &mut transaction, &redis).await?; + let res = crate::models::sessions::Session::from(session, true); + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(res)) + } else { + Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )) + } +} + +#[get("2fa")] +pub async fn begin_2fa_flow( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + if !user.has_totp.unwrap_or(false) { + let string = totp_rs::Secret::generate_secret(); + let encoded = string.to_encoded(); + + let flow = Flow::Initialize2FA { + user_id: user.id.into(), + secret: encoded.to_string(), + } + .insert(Utc::now() + Duration::minutes(30), &redis) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "secret": encoded.to_string(), + "flow": flow, + }))) + } else { + Err(ApiError::InvalidInput( + "User already has 2FA enabled on their account!".to_string(), + )) + } +} + +#[post("2fa")] +pub async fn finish_2fa_flow( + req: HttpRequest, + pool: Data, + redis: Data, + login: web::Json, + session_queue: Data, +) -> Result { + let flow = Flow::get(&login.flow, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if let Flow::Initialize2FA { user_id, secret } = flow { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + if user.id != user_id.into() { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let token = get_2fa_code(secret.clone())?; + + if token != login.code { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + Flow::remove(&login.flow, &redis).await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE users + SET totp_secret = $1 + WHERE (id = $2) + ", + secret, + user_id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM user_backup_codes + WHERE user_id = $1 + ", + user_id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + let mut codes = Vec::new(); + + for _ in 0..6 { + let mut rng = ChaCha20Rng::from_entropy(); + let val = random_base62_rng(&mut rng, 11); + + sqlx::query!( + " + INSERT INTO user_backup_codes ( + user_id, code + ) + VALUES ( + $1, $2 + ) + ", + user_id as crate::database::models::ids::UserId, + val as i64, + ) + .execute(&mut *transaction) + .await?; + + codes.push(to_base62(val)); + } + + crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?; + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "backup_codes": codes, + }))) + } else { + Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )) + } +} + +#[derive(Deserialize)] +pub struct Remove2FA { + pub code: String, +} + +#[delete("2fa")] +pub async fn remove_2fa( + req: HttpRequest, + pool: Data, + redis: Data, + login: web::Json, + session_queue: Data, +) -> Result { + let (scopes, user) = + get_user_record_from_bearer_token(&req, None, &**pool, &redis, &session_queue) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if !scopes.contains(Scopes::USER_AUTH_WRITE) { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let token = get_2fa_code(user.totp_secret.ok_or_else(|| { + ApiError::InvalidInput("User does not have 2FA enabled on the account!".to_string()) + })?)?; + + if token != login.code { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + let mut transaction = pool.begin().await?; - let session = issue_session(req, user_id, &mut transaction, &redis).await?; - let res = crate::models::sessions::Session::from(session, true); + + sqlx::query!( + " + UPDATE users + SET totp_secret = NULL + WHERE (id = $1) + ", + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM user_backup_codes + WHERE user_id = $1 + ", + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + crate::database::models::User::clear_caches(&[(user.id, None)], &redis).await?; + transaction.commit().await?; - Ok(HttpResponse::Ok().json(res)) + Ok(HttpResponse::NoContent().finish()) } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index fb6b6785..d571e516 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -38,6 +38,8 @@ pub enum AuthenticationError { InvalidAuthMethod, #[error("GitHub Token from incorrect Client ID")] InvalidClientId, + #[error("User email/account is already registered on Modrinth")] + DuplicateUser, #[error("Invalid callback URL specified")] Url, } @@ -56,6 +58,7 @@ impl actix_web::ResponseError for AuthenticationError { AuthenticationError::InvalidClientId => StatusCode::UNAUTHORIZED, AuthenticationError::Url => StatusCode::BAD_REQUEST, AuthenticationError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR, + AuthenticationError::DuplicateUser => StatusCode::BAD_REQUEST, } } @@ -73,6 +76,7 @@ impl actix_web::ResponseError for AuthenticationError { AuthenticationError::InvalidClientId => "invalid_client_id", AuthenticationError::Url => "url_error", AuthenticationError::FileHosting(..) => "file_hosting", + AuthenticationError::DuplicateUser => "duplicate_user", }, description: &self.to_string(), }) diff --git a/src/auth/validate.rs b/src/auth/validate.rs index 1addb234..d317062d 100644 --- a/src/auth/validate.rs +++ b/src/auth/validate.rs @@ -19,34 +19,38 @@ pub async fn get_user_from_headers<'a, E>( where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { - let headers = req.headers(); - let token: Option<&HeaderValue> = headers.get(AUTHORIZATION); - // Fetch DB user record and minos user from headers - let (scopes, db_user) = get_user_record_from_bearer_token( - req, - token - .ok_or_else(|| AuthenticationError::InvalidAuthMethod)? - .to_str() - .map_err(|_| AuthenticationError::InvalidCredentials)?, - executor, - redis, - session_queue, - ) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let (scopes, db_user) = + get_user_record_from_bearer_token(req, None, executor, redis, session_queue) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + let mut auth_providers = Vec::new(); + if db_user.github_id.is_some() { + auth_providers.push(AuthProvider::GitHub) + } + if db_user.gitlab_id.is_some() { + auth_providers.push(AuthProvider::GitLab) + } + if db_user.discord_id.is_some() { + auth_providers.push(AuthProvider::Discord) + } + if db_user.google_id.is_some() { + auth_providers.push(AuthProvider::Google) + } + if db_user.microsoft_id.is_some() { + auth_providers.push(AuthProvider::Microsoft) + } + if db_user.steam_id.is_some() { + auth_providers.push(AuthProvider::Steam) + } let user = User { id: UserId::from(db_user.id), - github_id: db_user.github_id.map(|x| x as u64), - // discord_id: minos_user.discord_id, - // google_id: minos_user.google_id, - // microsoft_id: minos_user.microsoft_id, - // apple_id: minos_user.apple_id, - // gitlab_id: minos_user.gitlab_id, username: db_user.username, name: db_user.name, email: db_user.email, + email_verified: Some(db_user.email_verified), avatar_url: db_user.avatar_url, bio: db_user.bio, created: db_user.created, @@ -58,6 +62,10 @@ where payout_wallet_type: db_user.payout_wallet_type, payout_address: db_user.payout_address, }), + auth_providers: Some(auth_providers), + has_password: Some(db_user.password.is_some()), + has_totp: Some(db_user.totp_secret.is_some()), + github_id: None, }; if let Some(required_scopes) = required_scopes { @@ -73,7 +81,7 @@ where pub async fn get_user_record_from_bearer_token<'a, 'b, E>( req: &HttpRequest, - token: &str, + token: Option<&str>, executor: E, redis: &deadpool_redis::Pool, session_queue: &AuthQueue, @@ -81,6 +89,17 @@ pub async fn get_user_record_from_bearer_token<'a, 'b, E>( where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { + let token = if let Some(token) = token { + token + } else { + let headers = req.headers(); + let token_val: Option<&HeaderValue> = headers.get(AUTHORIZATION); + token_val + .ok_or_else(|| AuthenticationError::InvalidAuthMethod)? + .to_str() + .map_err(|_| AuthenticationError::InvalidCredentials)? + }; + let possible_user = match token.split_once('_') { Some(("mrp", _)) => { let pat = diff --git a/src/database/models/flow_item.rs b/src/database/models/flow_item.rs new file mode 100644 index 00000000..8349a4c4 --- /dev/null +++ b/src/database/models/flow_item.rs @@ -0,0 +1,88 @@ +use super::ids::*; +use crate::auth::flows::AuthProvider; +use crate::database::models::DatabaseError; +use chrono::{DateTime, Timelike, Utc}; +use rand::distributions::Alphanumeric; +use rand::Rng; +use rand_chacha::rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; +use redis::cmd; +use serde::{Deserialize, Serialize}; + +const FLOWS_NAMESPACE: &str = "flows"; + +#[derive(Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Flow { + OAuth { + user_id: Option, + url: String, + provider: AuthProvider, + }, + Login2FA { + user_id: UserId, + }, + Initialize2FA { + user_id: UserId, + secret: String, + }, + ForgotPassword { + user_id: UserId, + }, + ConfirmEmail { + user_id: UserId, + confirm_email: String, + }, +} + +impl Flow { + pub async fn insert( + &self, + expires: DateTime, + redis: &deadpool_redis::Pool, + ) -> Result { + let mut redis = redis.get().await?; + + let flow = ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect::(); + + cmd("SET") + .arg(format!("{}:{}", FLOWS_NAMESPACE, flow)) + .arg(serde_json::to_string(&self)?) + .arg("EX") + .arg(expires.second()) + .query_async::<_, ()>(&mut redis) + .await?; + + Ok(flow) + } + + pub async fn get( + id: &str, + redis: &deadpool_redis::Pool, + ) -> Result, DatabaseError> { + let mut redis = redis.get().await?; + + let res = cmd("GET") + .arg(format!("{}:{}", FLOWS_NAMESPACE, id)) + .query_async::<_, Option>(&mut redis) + .await?; + + Ok(res.and_then(|x| serde_json::from_str(&x).ok())) + } + + pub async fn remove( + id: &str, + redis: &deadpool_redis::Pool, + ) -> Result, DatabaseError> { + let mut redis = redis.get().await?; + let mut cmd = cmd("DEL"); + cmd.arg(format!("{}:{}", FLOWS_NAMESPACE, id)); + cmd.query_async::<_, ()>(&mut redis).await?; + + Ok(Some(())) + } +} diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 481bf9bf..21cd773d 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -76,13 +76,6 @@ generate_ids!( "SELECT EXISTS(SELECT 1 FROM team_members WHERE id=$1)", TeamMemberId ); -generate_ids!( - pub generate_state_id, - StateId, - 8, - "SELECT EXISTS(SELECT 1 FROM states WHERE id=$1)", - StateId -); generate_ids!( pub generate_pat_id, PatId, @@ -189,10 +182,6 @@ pub struct ReportTypeId(pub i32); #[sqlx(transparent)] pub struct FileId(pub i64); -#[derive(Copy, Clone, Debug, Type)] -#[sqlx(transparent)] -pub struct StateId(pub i64); - #[derive(Copy, Clone, Debug, Type, Deserialize, Serialize)] #[sqlx(transparent)] pub struct PatId(pub i64); diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 0aa8fbd1..e175ab03 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -1,6 +1,7 @@ use thiserror::Error; pub mod categories; +pub mod flow_item; pub mod ids; pub mod notification_item; pub mod pat_item; diff --git a/src/database/models/report_item.rs b/src/database/models/report_item.rs index 89c9a44c..4afbbf05 100644 --- a/src/database/models/report_item.rs +++ b/src/database/models/report_item.rs @@ -135,7 +135,8 @@ impl Report { .await?; if let Some(thread_id) = thread_id { - crate::database::models::Thread::remove_full(ThreadId(thread_id.id), transaction).await?; + crate::database::models::Thread::remove_full(ThreadId(thread_id.id), transaction) + .await?; } sqlx::query!( diff --git a/src/database/models/user_item.rs b/src/database/models/user_item.rs index af9cb55d..6eeea1d0 100644 --- a/src/database/models/user_item.rs +++ b/src/database/models/user_item.rs @@ -24,6 +24,8 @@ pub struct User { pub microsoft_id: Option, pub password: Option, + pub totp_secret: Option, + pub username: String, pub name: Option, pub email: Option, @@ -204,7 +206,7 @@ impl User { created, role, badges, balance, payout_wallet, payout_wallet_type, payout_address, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, - email_verified, password + email_verified, password, totp_secret FROM users WHERE id = ANY($1) OR LOWER(username) = ANY($2) ", @@ -240,6 +242,7 @@ impl User { .map(|x| RecipientType::from_string(&x)), payout_address: u.payout_address, password: u.password, + totp_secret: u.totp_secret, })) }) .try_collect::>() @@ -272,6 +275,23 @@ impl User { Ok(found_users) } + pub async fn get_email<'a, E>(email: &str, exec: E) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let user_pass = sqlx::query!( + " + SELECT id FROM users + WHERE email = $1 + ", + email + ) + .fetch_optional(exec) + .await?; + + Ok(user_pass.map(|x| UserId(x.id))) + } + pub async fn get_projects<'a, E>( user_id: UserId, exec: E, @@ -298,6 +318,30 @@ impl User { Ok(projects) } + pub async fn get_backup_codes<'a, E>( + user_id: UserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let codes = sqlx::query!( + " + SELECT code FROM user_backup_codes + WHERE user_id = $1 + ", + user_id as UserId, + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|m| to_base62(m.code as u64))) }) + .try_collect::>() + .await?; + + Ok(codes) + } + pub async fn clear_caches( user_ids: &[(UserId, Option)], redis: &deadpool_redis::Pool, @@ -486,6 +530,36 @@ impl User { .execute(&mut *transaction) .await?; + sqlx::query!( + " + DELETE FROM sessions + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM pats + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM user_backup_codes + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut *transaction) + .await?; + sqlx::query!( " DELETE FROM users diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 8847dab3..78f62567 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -1,6 +1,5 @@ use super::ids::*; use super::DatabaseError; -use crate::models::ids::base62_impl::parse_base62; use crate::models::projects::{FileType, VersionStatus}; use chrono::{DateTime, Utc}; use itertools::Itertools; @@ -751,40 +750,6 @@ impl Version { Ok(()) } - - // TODO: Needs to be cached - pub async fn get_full_from_id_slug<'a, 'b, E>( - project_id_or_slug: &str, - slug: &str, - executor: E, - redis: &deadpool_redis::Pool, - ) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, - { - let project_id_opt = parse_base62(project_id_or_slug).ok().map(|x| x as i64); - let id_opt = parse_base62(slug).ok().map(|x| x as i64); - let id = sqlx::query!( - " - SELECT v.id FROM versions v - INNER JOIN mods m ON mod_id = m.id - WHERE (m.id = $1 OR m.slug = $2) AND (v.id = $3 OR v.version_number = $4) - ORDER BY date_published ASC - ", - project_id_opt, - project_id_or_slug, - id_opt, - slug - ) - .fetch_optional(executor) - .await?; - - if let Some(version_id) = id { - Ok(Version::get(VersionId(version_id.id), executor, redis).await?) - } else { - Ok(None) - } - } } #[derive(Clone, Deserialize, Serialize)] diff --git a/src/main.rs b/src/main.rs index 5c92498a..dba96b65 100644 --- a/src/main.rs +++ b/src/main.rs @@ -130,34 +130,6 @@ async fn main() -> std::io::Result<()> { } }); - // Deleting old authentication states from the database every 15 minutes - let pool_ref = pool.clone(); - scheduler.run(std::time::Duration::from_secs(15 * 60), move || { - let pool_ref = pool_ref.clone(); - // Use sqlx to delete records more than an hour old - info!("Deleting old records from temporary tables"); - - async move { - let states_result = sqlx::query!( - " - DELETE FROM states - WHERE expires < CURRENT_DATE - " - ) - .execute(&pool_ref) - .await; - - if let Err(e) = states_result { - warn!( - "Deleting old records from temporary table states failed: {:?}", - e - ); - } - - info!("Finished deleting old records from temporary tables"); - } - }); - // Changes statuses of scheduled projects/versions let pool_ref = pool.clone(); // TODO: Clear cache when these are run diff --git a/src/models/threads.rs b/src/models/threads.rs index 49ad6807..4f7d9623 100644 --- a/src/models/threads.rs +++ b/src/models/threads.rs @@ -1,9 +1,9 @@ use super::ids::Base62Id; +use crate::models::ids::{ProjectId, ReportId}; use crate::models::projects::ProjectStatus; use crate::models::users::{User, UserId}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::models::ids::{ProjectId, ReportId}; #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(from = "Base62Id")] diff --git a/src/models/users.rs b/src/models/users.rs index b138b331..7b1a2a98 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -1,4 +1,5 @@ use super::ids::Base62Id; +use crate::auth::flows::AuthProvider; use chrono::{DateTime, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -39,19 +40,21 @@ pub struct User { pub id: UserId, pub username: String, pub name: Option, - pub email: Option, pub avatar_url: Option, pub bio: Option, pub created: DateTime, pub role: Role, pub badges: Badges, + pub payout_data: Option, + pub auth_providers: Option>, + pub email: Option, + pub email_verified: Option, + pub has_password: Option, + pub has_totp: Option, + + // DEPRECATED. Always returns None pub github_id: Option, - // pub discord_id: Option, - // pub google_id: Option, - // pub microsoft_id: Option, - // pub apple_id: Option, - // pub gitlab_id: Option, } #[derive(Serialize, Deserialize, Clone)] @@ -138,18 +141,17 @@ impl From for User { username: data.username, name: data.name, email: None, + email_verified: None, avatar_url: data.avatar_url, bio: data.bio, created: data.created, role: Role::from_string(&data.role), badges: data.badges, payout_data: None, + auth_providers: None, + has_password: None, + has_totp: None, github_id: None, - // discord_id: None, - // google_id: None, - // microsoft_id: None, - // apple_id: None, - // gitlab_id: None, } } } diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 87af9385..39430efb 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -785,8 +785,8 @@ async fn project_create_inner( project_id: Some(id), report_id: None, } - .insert(&mut *transaction) - .await?; + .insert(&mut *transaction) + .await?; let response = crate::models::projects::Project { id: project_id, diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index f4aa2075..dd611317 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -584,8 +584,8 @@ pub async fn project_edit( }, thread_id: project_item.thread_id, } - .insert(&mut transaction) - .await?; + .insert(&mut transaction) + .await?; sqlx::query!( " diff --git a/src/routes/v2/reports.rs b/src/routes/v2/reports.rs index 451e425b..33c69b5c 100644 --- a/src/routes/v2/reports.rs +++ b/src/routes/v2/reports.rs @@ -153,8 +153,8 @@ pub async fn report_create( project_id: None, report_id: Some(report.id), } - .insert(&mut transaction) - .await?; + .insert(&mut transaction) + .await?; transaction.commit().await?; @@ -395,8 +395,8 @@ pub async fn report_edit( }, thread_id: report.thread_id, } - .insert(&mut transaction) - .await?; + .insert(&mut transaction) + .await?; sqlx::query!( " diff --git a/src/routes/v2/threads.rs b/src/routes/v2/threads.rs index 89b67cee..b9f53906 100644 --- a/src/routes/v2/threads.rs +++ b/src/routes/v2/threads.rs @@ -45,12 +45,14 @@ pub async fn is_authorized_thread( report_id as database::models::ids::ReportId, user_id as database::models::ids::UserId, ) - .fetch_one(pool) - .await? - .exists; + .fetch_one(pool) + .await? + .exists; report_exists.unwrap_or(false) - } else { false } + } else { + false + } } ThreadType::Project => { if let Some(project_id) = thread.project_id { @@ -379,12 +381,7 @@ pub async fn thread_send_message( .await?; let mod_notif = if let Some(project_id) = thread.project_id { - let project = database::models::Project::get_id( - project_id, - &**pool, - &redis, - ) - .await?; + let project = database::models::Project::get_id(project_id, &**pool, &redis).await?; if let Some(project) = project { if project.inner.status != ProjectStatus::Processing && user.role.is_mod() { @@ -393,7 +390,7 @@ pub async fn thread_send_message( &**pool, &redis, ) - .await?; + .await?; NotificationBuilder { body: NotificationBody::ModeratorMessage { @@ -403,11 +400,11 @@ pub async fn thread_send_message( report_id: None, }, } - .insert_many( - members.into_iter().map(|x| x.user_id).collect(), - &mut transaction, - ) - .await?; + .insert_many( + members.into_iter().map(|x| x.user_id).collect(), + &mut transaction, + ) + .await?; } project.inner.status == ProjectStatus::Processing && !user.role.is_mod() @@ -415,11 +412,7 @@ pub async fn thread_send_message( !user.role.is_mod() } } else if let Some(report_id) = thread.report_id { - let report = database::models::report_item::Report::get( - report_id, - &**pool, - ) - .await?; + let report = database::models::report_item::Report::get(report_id, &**pool).await?; if let Some(report) = report { if report.closed && !user.role.is_mod() { diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index f9630c42..380744b7 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -1,3 +1,4 @@ +use crate::auth::flows::AuthProvider; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::User; use crate::file_hosting::FileHost; @@ -193,6 +194,7 @@ pub struct EditUser { #[validate] pub payout_data: Option>, pub password: Option<(Option, Option)>, + pub remove_auth_providers: Option>, } #[derive(Serialize, Deserialize, Validate)] @@ -412,7 +414,7 @@ pub async fn user_edit( )); } - if let Some(pass) = actual_user.password { + if let Some(pass) = actual_user.password.as_ref() { let old_password = old_password.as_ref().ok_or_else(|| { ApiError::CustomAuthentication( "You must specify the old password to change your password!" @@ -421,7 +423,7 @@ pub async fn user_edit( })?; let hasher = Argon2::default(); - hasher.verify_password(old_password.as_bytes(), &PasswordHash::new(&pass)?)?; + hasher.verify_password(old_password.as_bytes(), &PasswordHash::new(pass)?)?; } let update_password = if let Some(new_password) = new_password { @@ -483,6 +485,116 @@ pub async fn user_edit( .await?; } + if let Some(remove_auth_providers) = &new_user.remove_auth_providers { + if !scopes.contains(Scopes::USER_AUTH_WRITE) { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let mut auth_providers = Vec::new(); + if actual_user.github_id.is_some() { + auth_providers.push(AuthProvider::GitHub) + } + if actual_user.gitlab_id.is_some() { + auth_providers.push(AuthProvider::GitLab) + } + if actual_user.discord_id.is_some() { + auth_providers.push(AuthProvider::Discord) + } + if actual_user.google_id.is_some() { + auth_providers.push(AuthProvider::Google) + } + if actual_user.microsoft_id.is_some() { + auth_providers.push(AuthProvider::Microsoft) + } + if actual_user.steam_id.is_some() { + auth_providers.push(AuthProvider::Steam) + } + + if auth_providers.len() <= remove_auth_providers.len() + && actual_user.password.is_none() + { + return Err(ApiError::InvalidInput( + "You must have another authentication method added to this method!" + .to_string(), + )); + } + + if remove_auth_providers.contains(&AuthProvider::GitHub) { + sqlx::query!( + " + UPDATE users + SET github_id = NULL + WHERE (id = $1) + ", + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + if remove_auth_providers.contains(&AuthProvider::GitLab) { + sqlx::query!( + " + UPDATE users + SET gitlab_id = NULL + WHERE (id = $1) + ", + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + if remove_auth_providers.contains(&AuthProvider::Google) { + sqlx::query!( + " + UPDATE users + SET google_id = NULL + WHERE (id = $1) + ", + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + if remove_auth_providers.contains(&AuthProvider::Steam) { + sqlx::query!( + " + UPDATE users + SET steam_id = NULL + WHERE (id = $1) + ", + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + if remove_auth_providers.contains(&AuthProvider::Discord) { + sqlx::query!( + " + UPDATE users + SET discord_id = NULL + WHERE (id = $1) + ", + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + if remove_auth_providers.contains(&AuthProvider::Microsoft) { + sqlx::query!( + " + UPDATE users + SET microsoft_id = NULL + WHERE (id = $1) + ", + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + } + User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 8367c762..a3080c1d 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -4,6 +4,7 @@ use crate::auth::{ }; use crate::database; use crate::models; +use crate::models::ids::base62_impl::parse_base62; use crate::models::pats::Scopes; use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; use crate::models::teams::Permissions; @@ -165,8 +166,8 @@ pub async fn version_project_get( session_queue: web::Data, ) -> Result { let id = info.into_inner(); - let version_data = - database::models::Version::get_full_from_id_slug(&id.0, &id.1, &**pool, &redis).await?; + + let result = database::models::Project::get(&id.0, &**pool, &redis).await?; let user_option = get_user_from_headers( &req, @@ -179,9 +180,23 @@ pub async fn version_project_get( .map(|x| x.1) .ok(); - if let Some(data) = version_data { - if is_authorized_version(&data.inner, &user_option, &pool).await? { - return Ok(HttpResponse::Ok().json(models::projects::Version::from(data))); + if let Some(project) = result { + if !is_authorized(&project.inner, &user_option, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + let versions = + database::models::Version::get_many(&project.versions, &**pool, &redis).await?; + + let id_opt = parse_base62(&id.1).ok(); + let version = versions + .into_iter() + .find(|x| Some(x.inner.id.0 as u64) == id_opt || x.inner.version_number == id.1); + + if let Some(version) = version { + if is_authorized_version(&version.inner, &user_option, &pool).await? { + return Ok(HttpResponse::Ok().json(models::projects::Version::from(version))); + } } }