diff --git a/.env b/.env index 765897317..bde828be7 100644 --- a/.env +++ b/.env @@ -71,3 +71,5 @@ GOOGLE_CLIENT_ID=none GOOGLE_CLIENT_SECRET=none STEAM_API_KEY=none + +TURNSTILE_SECRET=none diff --git a/Cargo.lock b/Cargo.lock index af789b1b3..ce48f2a33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -366,6 +366,17 @@ dependencies = [ "libc", ] +[[package]] +name = "argon2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c2fcf79ad1932ac6269a738109997a83c227c09b75842ae564dc8ede6a861c" +dependencies = [ + "base64ct", + "blake2", + "password-hash 0.5.0", +] + [[package]] name = "arrayvec" version = "0.5.2" @@ -482,6 +493,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.13.1" @@ -509,6 +526,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit_field" version = "0.10.2" @@ -539,6 +571,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -818,6 +859,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "constant_time_eq" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" + [[package]] name = "convert_case" version = "0.4.0" @@ -1128,6 +1175,37 @@ dependencies = [ "uuid 1.4.0", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -1267,6 +1345,16 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -1986,6 +2074,7 @@ dependencies = [ "actix-multipart", "actix-rt", "actix-web", + "argon2", "async-trait", "base64 0.21.2", "bitflags 1.3.2", @@ -2025,6 +2114,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "totp-rs", "url", "urlencoding", "validator", @@ -2033,6 +2123,7 @@ dependencies = [ "yaserde", "yaserde_derive", "zip", + "zxcvbn", ] [[package]] @@ -2622,6 +2713,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "paste" version = "1.0.13" @@ -2636,7 +2738,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", "hmac 0.12.1", - "password-hash", + "password-hash 0.4.2", "sha2 0.10.7", ] @@ -2842,6 +2944,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.26.0" @@ -4128,6 +4236,19 @@ dependencies = [ "serde", ] +[[package]] +name = "totp-rs" +version = "5.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad5e73765ff14ae797c1a61ee0c7beaf21b4e4a0047844300e332c6c24df1fc" +dependencies = [ + "base32", + "constant_time_eq 0.2.6", + "hmac 0.12.1", + "sha1 0.10.5", + "sha2 0.10.7", +] + [[package]] name = "tower-service" version = "0.3.2" @@ -4714,7 +4835,7 @@ dependencies = [ "aes", "byteorder", "bzip2", - "constant_time_eq", + "constant_time_eq 0.1.5", "crc32fast", "crossbeam-utils", "flate2", @@ -4782,3 +4903,19 @@ checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" dependencies = [ "simd-adler32", ] + +[[package]] +name = "zxcvbn" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103fa851fff70ea29af380e87c25c48ff7faac5c530c70bd0e65366d4e0c94e4" +dependencies = [ + "derive_builder", + "fancy-regex", + "itertools 0.10.5", + "js-sys", + "lazy_static", + "quick-error", + "regex", + "time 0.3.22", +] diff --git a/Cargo.toml b/Cargo.toml index dd5b2d010..de1db6703 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,8 +45,11 @@ base64 = "0.21.2" sha1 = { version = "0.6.1", features = ["std"] } sha2 = "0.9.9" hmac = "0.11.0" +argon2 = { version = "0.5.0", features = ["std"] } bitflags = "1.3.2" hex = "0.4.3" +zxcvbn = "2.2.2" +totp-rs = "5.0.2" url = "2.4.0" urlencoding = "2.1.2" diff --git a/sqlx-data.json b/sqlx-data.json index 06a76b83f..a8255861c 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -397,19 +397,67 @@ }, "query": "\n INSERT INTO files (id, version_id, url, filename, is_primary, size, file_type)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n " }, - "0d737f82a07a1cd578c4c14c9f718d872068d17e4b122c60765cbb3328b2378a": { + "0d91a3a73844f46ef00d8d45a0d028f1c4c1da016044f63f21d96707eafec858": { "describe": { - "columns": [], - "nullable": [], + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "team_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "member_role", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "permissions", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "accepted", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "payouts_split", + "ordinal": 5, + "type_info": "Numeric" + }, + { + "name": "ordering", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 7, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ], "parameters": { "Left": [ - "Varchar", - "Timestamp", - "Int8" + "Int8Array" ] } }, - "query": "\n UPDATE pats SET\n name = $1,\n expires_at = $2\n WHERE id = $3\n " + "query": "\n SELECT tm.id id, tm.team_id team_id, tm.role member_role, tm.permissions permissions, tm.accepted accepted, tm.payouts_split payouts_split, tm.ordering,\n tm.user_id user_id\n FROM team_members tm\n WHERE tm.team_id = ANY($1)\n ORDER BY tm.team_id, tm.ordering\n " }, "0f29bb5ba767ebd0669c860994e48e3cb2674f0d53f6c4ab85c79d46b04cbb40": { "describe": { @@ -919,23 +967,6 @@ }, "query": "\n SELECT id FROM side_types\n WHERE name = $1\n " }, - "1ee84c22602345af913657875dfde3208d71101de75955cbb6f18214d16c3d2f": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Varchar", - "Varchar", - "Int8", - "Int8", - "Timestamp" - ] - } - }, - "query": "\n INSERT INTO pats (id, name, access_token, user_id, scope, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6)\n " - }, "1ffce9b2d5c9fa6c8b9abce4bad9f9419c44ad6367b7463b979c91b9b5b4fea1": { "describe": { "columns": [ @@ -1259,6 +1290,58 @@ }, "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1\n " }, + "2e5ddc7876d8041fec781893027f84b49b5794c85fa442296c35156d0a72464a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Int8", + "Int8", + "Int8", + "Varchar", + "Int8", + "Varchar", + "Bool", + "Text" + ] + } + }, + "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": [ @@ -1522,140 +1605,6 @@ }, "query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n LIMIT $2;\n " }, - "3bb3bcad044ebb7f94e9a73661295345f413c65b15e27b98fc9481caac46b48e": { - "describe": { - "columns": [ - { - "name": "expires_at", - "ordinal": 0, - "type_info": "Timestamp" - }, - { - "name": "id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "name", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "email", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "avatar_url", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 5, - "type_info": "Varchar" - }, - { - "name": "bio", - "ordinal": 6, - "type_info": "Varchar" - }, - { - "name": "created", - "ordinal": 7, - "type_info": "Timestamptz" - }, - { - "name": "role", - "ordinal": 8, - "type_info": "Varchar" - }, - { - "name": "badges", - "ordinal": 9, - "type_info": "Int8" - }, - { - "name": "balance", - "ordinal": 10, - "type_info": "Numeric" - }, - { - "name": "payout_wallet", - "ordinal": 11, - "type_info": "Varchar" - }, - { - "name": "payout_wallet_type", - "ordinal": 12, - "type_info": "Varchar" - }, - { - "name": "payout_address", - "ordinal": 13, - "type_info": "Varchar" - }, - { - "name": "github_id", - "ordinal": 14, - "type_info": "Int8" - }, - { - "name": "discord_id", - "ordinal": 15, - "type_info": "Int8" - }, - { - "name": "gitlab_id", - "ordinal": 16, - "type_info": "Int8" - }, - { - "name": "google_id", - "ordinal": 17, - "type_info": "Varchar" - }, - { - "name": "steam_id", - "ordinal": 18, - "type_info": "Int8" - }, - { - "name": "microsoft_id", - "ordinal": 19, - "type_info": "Varchar" - } - ], - "nullable": [ - false, - false, - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT pats.expires_at,\n u.id, u.name, u.email,\n u.avatar_url, u.username, u.bio,\n u.created, u.role, u.badges,\n u.balance, u.payout_wallet, u.payout_wallet_type, u.payout_address,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id\n FROM pats LEFT OUTER JOIN users u ON pats.user_id = u.id\n WHERE access_token = $1\n " - }, "3bdcbfa5abe43cc9b4f996f147277a7f6921cca00f82cad0ef5d85032c761a36": { "describe": { "columns": [], @@ -1971,135 +1920,6 @@ }, "query": "\n DELETE FROM mods_gallery\n WHERE id = $1\n " }, - "4dfc14e7ba6fe3a8e0e078d91efef33743be2939838a6e621c8abeaadc12ff29": { - "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" - } - ], - "nullable": [ - false, - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - true, - true, - true, - true, - true, - true, - 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 FROM users\n WHERE id = ANY($1) OR username = ANY($2)\n " - }, "4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955": { "describe": { "columns": [], @@ -2553,27 +2373,6 @@ }, "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 " }, - "603ce7394c4b3e0790bf6a590199c72c50a54304112dc0b7cd91654f6aebf37c": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - } - }, - "query": "\n SELECT id FROM pats\n WHERE id = $1 AND user_id = $2\n " - }, "61a7f29e024bf2f1368370e3f6e8ef70317c7e8545b5b6d4235f21164948ba27": { "describe": { "columns": [], @@ -2587,109 +2386,24 @@ }, "query": "\n UPDATE mods_gallery\n SET featured = $2\n WHERE mod_id = $1\n " }, - "61ebde440ef7ce59c38d92725b93b666ee22e04d6237438c2579d771f5b51240": { + "64d5e7cfb8472fbedcd06143db0db2f4c9677c42f73c540e85ccb5aee1a7b6f9": { "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "team_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "member_role", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "permissions", - "ordinal": 3, - "type_info": "Int8" - }, - { - "name": "accepted", - "ordinal": 4, - "type_info": "Bool" - }, - { - "name": "payouts_split", - "ordinal": 5, - "type_info": "Numeric" - }, - { - "name": "ordering", - "ordinal": 6, - "type_info": "Int8" - }, - { - "name": "user_id", - "ordinal": 7, - "type_info": "Int8" - }, - { - "name": "user_name", - "ordinal": 8, - "type_info": "Varchar" - }, - { - "name": "avatar_url", - "ordinal": 9, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 10, - "type_info": "Varchar" - }, - { - "name": "bio", - "ordinal": 11, - "type_info": "Varchar" - }, - { - "name": "created", - "ordinal": 12, - "type_info": "Timestamptz" - }, - { - "name": "user_role", - "ordinal": 13, - "type_info": "Varchar" - }, - { - "name": "badges", - "ordinal": 14, - "type_info": "Int8" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - false, - true, - false, - false, - false - ], + "columns": [], + "nullable": [], "parameters": { "Left": [ - "Int8Array" + "Int8", + "Timestamptz", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" ] } }, - "query": "\n SELECT tm.id id, tm.team_id team_id, tm.role member_role, tm.permissions permissions, tm.accepted accepted, tm.payouts_split payouts_split, tm.ordering,\n u.id user_id, u.name user_name,\n u.avatar_url avatar_url, u.username username, u.bio bio,\n u.created created, u.role user_role, u.badges badges\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = ANY($1)\n ORDER BY tm.team_id, tm.ordering\n " + "query": "\n UPDATE sessions\n SET last_login = $2, city = $3, country = $4, ip = $5, os = $6, platform = $7, user_agent = $8\n WHERE (id = $1)\n " }, "665e294e9737fd0299fc4639127d56811485dc8a5a4e08a4e7292044d8a2fb7a": { "describe": { @@ -2951,6 +2665,36 @@ }, "query": "\n INSERT INTO team_members (\n id, team_id, user_id, role, permissions, accepted\n )\n VALUES (\n $1, $2, $3, $4, $5, $6\n )\n " }, + "7028615c2af313f48ce68addef860b6e15a9736117cd64ec96277487d54d5964": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "session", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "\n SELECT id, session, user_id\n FROM sessions\n WHERE refresh_expires >= NOW()\n " + }, "70b510956a40583eef8c57dcced71c67f525eee455ae8b09e9b2403668068751": { "describe": { "columns": [], @@ -3284,6 +3028,19 @@ }, "query": "\n SELECT n.id FROM notifications n\n WHERE n.user_id = $1\n " }, + "7b284f2c766ab64c57309f903d2c456ff74e06ea5f8454f0303215a5ad2cc93f": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET password = $1\n WHERE (id = $2)\n " + }, "7c0cdacf0898155c94008a96a0b918550df4475b9e3362a926d4d00e001880c1": { "describe": { "columns": [ @@ -3832,6 +3589,147 @@ }, "query": "\n DELETE FROM payouts_values\n WHERE user_id = $1\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 " + }, "97690dda7edea8c985891cae5ad405f628ed81e333bc88df5493c928a4324d43": { "describe": { "columns": [ @@ -3937,26 +3835,6 @@ }, "query": "\n INSERT INTO team_members (id, team_id, user_id, role, permissions, accepted, payouts_split, ordering)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n " }, - "9d74e3d45e49dc2a7ac50b4ab233f96dbf39f6fa112df94e991b00444e9ab6ca": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT EXISTS(SELECT 1 FROM pats WHERE access_token=$1)\n " - }, "a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af": { "describe": { "columns": [], @@ -4374,26 +4252,6 @@ }, "query": "\n DELETE FROM mods_donations\n WHERE joining_mod_id = $1\n " }, - "ad27195af9964c34803343c22abcb9aa6b52f2d1a370550ed4fb68bce2297e71": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "SELECT EXISTS(SELECT 1 FROM pats WHERE id=$1)" - }, "ae1686b8b566dd7ecc57c653c9313a4b324a2ec3a63aa6a44ed1d8ea7999b115": { "describe": { "columns": [], @@ -4622,30 +4480,6 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM reports WHERE thread_id = $1 AND reporter = $2)" }, - "bedb0bdf803671138449d3e46e6dc5c63f9d01ea93e447ee69c99d3f29c89ab3": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Int8", - "Int8", - "Int8", - "Varchar", - "Int8", - "Varchar" - ] - } - }, - "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 )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13\n )\n " - }, "bee1abe8313d17a56d93b06a31240e338c3973bc7a7374799ced3df5e38d3134": { "describe": { "columns": [], @@ -5078,50 +4912,6 @@ }, "query": "\n INSERT INTO hashes (file_id, algorithm, hash)\n VALUES ($1, $2, $3)\n " }, - "cc58fd2c0aca4576a0fda37e7b2f183a6cda482f24e3cca7b2c6e31cb8d0d728": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "user_id", - "ordinal": 2, - "type_info": "Int8" - }, - { - "name": "scope", - "ordinal": 3, - "type_info": "Int8" - }, - { - "name": "expires_at", - "ordinal": 4, - "type_info": "Timestamp" - } - ], - "nullable": [ - false, - true, - false, - false, - false - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n SELECT id, name, user_id, scope, expires_at\n FROM pats\n WHERE user_id = $1\n " - }, "ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c": { "describe": { "columns": [ @@ -5782,51 +5572,6 @@ }, "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n " }, - "ebbd2105c456a9b462a4e5ace356345ff18ad6cbcdfc053afe86948f8f3ae092": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "scope", - "ordinal": 2, - "type_info": "Int8" - }, - { - "name": "user_id", - "ordinal": 3, - "type_info": "Int8" - }, - { - "name": "expires_at", - "ordinal": 4, - "type_info": "Timestamp" - } - ], - "nullable": [ - false, - true, - false, - false, - false - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - } - }, - "query": "\n SELECT id, name, scope, user_id, expires_at FROM pats\n WHERE id = $1 AND user_id = $2\n " - }, "ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4": { "describe": { "columns": [], @@ -6302,17 +6047,5 @@ } }, "query": "\n SELECT follower_id FROM mod_follows\n WHERE mod_id = $1\n " - }, - "fe42090b9d81a660c36500eb3c017c1794839d206550239fd1322019c042b3d9": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM pats\n WHERE id = $1\n " } } \ No newline at end of file diff --git a/src/auth/flows.rs b/src/auth/flows.rs index 454cf9918..2429a3c05 100644 --- a/src/auth/flows.rs +++ b/src/auth/flows.rs @@ -1,26 +1,33 @@ -use crate::database::models::{generate_state_id, StateId}; -use crate::models::ids::base62_impl::{parse_base62, to_base62}; -use std::collections::HashMap; -use std::sync::Arc; - -use crate::parse_strings_from_var; - -use actix_web::web::{scope, Data, Query, ServiceConfig}; -use actix_web::{get, HttpRequest, HttpResponse}; -use chrono::Utc; -use reqwest::header::AUTHORIZATION; -use rust_decimal::Decimal; - use crate::auth::session::issue_session; use crate::auth::AuthenticationError; +use crate::database::models::{generate_state_id, StateId}; use crate::file_hosting::FileHost; +use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::users::{Badges, Role}; +use crate::parse_strings_from_var; +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 argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use chrono::Utc; +use rand_chacha::rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; +use reqwest::header::AUTHORIZATION; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use validator::Validate; pub fn config(cfg: &mut ServiceConfig) { - cfg.service(scope("auth").service(auth_callback).service(init)); + cfg.service(scope("auth").service(auth_callback).service(init)) + .service(create_account_with_password) + .service(login_password); } #[derive(Serialize, Deserialize, Default, Eq, PartialEq)] @@ -849,9 +856,11 @@ pub async fn auth_callback( } 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(), @@ -887,3 +896,155 @@ pub async fn auth_callback( Err(AuthenticationError::InvalidCredentials) } } + +#[derive(Deserialize, Validate)] +pub struct NewAccount { + #[validate(length(min = 1, max = 39), regex = "RE_URL_SAFE")] + pub username: String, + #[validate(length(min = 8, max = 256))] + pub password: String, + #[validate(email)] + pub email: String, + pub challenge: String, +} + +#[post("create")] +pub async fn create_account_with_password( + req: HttpRequest, + pool: Data, + redis: Data, + new_account: web::Json, +) -> Result { + new_account + .0 + .validate() + .map_err(|err| ApiError::InvalidInput(validation_errors_to_string(err, None)))?; + + if check_turnstile_captcha(&req, &new_account.challenge).await? { + return Err(ApiError::Turnstile); + } + + if crate::database::models::User::get(&new_account.username, &**pool, &redis) + .await? + .is_some() + { + return Err(ApiError::InvalidInput("Username is taken!".to_string())); + } + + let mut transaction = pool.begin().await?; + let user_id = crate::database::models::generate_user_id(&mut transaction).await?; + + let new_account = new_account.0; + + let score = zxcvbn::zxcvbn( + &new_account.password, + &[&new_account.username, &new_account.email], + )?; + + if score.score() < 3 { + return Err(ApiError::InvalidInput( + if let Some(feedback) = score.feedback().clone().and_then(|x| x.warning()) { + format!("Password too weak: {}", feedback) + } else { + "Specified password is too weak! Please improve its strength.".to_string() + }, + )); + } + + let hasher = Argon2::default(); + let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy()); + let password_hash = hasher + .hash_password(new_account.password.as_bytes(), &salt)? + .to_string(); + + crate::database::models::User { + id: user_id, + github_id: None, + discord_id: None, + gitlab_id: None, + google_id: None, + steam_id: None, + microsoft_id: None, + password: Some(password_hash), + username: new_account.username.clone(), + name: Some(new_account.username), + email: Some(new_account.email), + email_verified: false, + avatar_url: None, + bio: None, + 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?; + let res = crate::models::sessions::Session::from(session, true); + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(res)) +} + +#[derive(Deserialize, Validate)] +pub struct Login { + pub username: String, + pub password: String, + pub challenge: String, +} + +#[post("login")] +pub async fn login_password( + req: HttpRequest, + pool: Data, + redis: Data, + login: web::Json, +) -> Result { + if check_turnstile_captcha(&req, &login.challenge).await? { + return Err(ApiError::Turnstile); + } + + let (user_id, password) = if let Some(user) = + crate::database::models::User::get(&login.username, &**pool, &redis).await? + { + ( + user.id, + user.password + .ok_or_else(|| AuthenticationError::InvalidCredentials)?, + ) + } else { + let user_pass = sqlx::query!( + " + SELECT id, password FROM users + WHERE email = $1 + ", + login.username + ) + .fetch_one(&**pool) + .await + .map_err(|_| AuthenticationError::InvalidCredentials)?; + + ( + crate::database::models::UserId(user_pass.id), + user_pass + .password + .ok_or_else(|| AuthenticationError::InvalidCredentials)?, + ) + }; + + let hasher = Argon2::default(); + hasher + .verify_password(login.password.as_bytes(), &PasswordHash::new(&password)?) + .map_err(|_| 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); + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(res)) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 6e0a58ebc..66d1c968c 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,14 +1,13 @@ pub mod checks; pub mod flows; pub mod pat; -mod session; +pub mod session; pub mod validate; pub use checks::{ filter_authorized_projects, filter_authorized_versions, is_authorized, is_authorized_version, }; -pub use flows::config; -pub use pat::{generate_pat, get_user_from_pat, PersonalAccessToken}; +// pub use pat::{generate_pat, PersonalAccessToken}; pub use validate::{check_is_moderator_from_headers, get_user_from_headers}; use crate::file_hosting::FileHostingError; @@ -29,6 +28,8 @@ pub enum AuthenticationError { SerDe(#[from] serde_json::Error), #[error("Error while communicating to external oauth provider")] Reqwest(#[from] reqwest::Error), + #[error("Error uploading user profile picture")] + FileHosting(#[from] FileHostingError), #[error("Error while decoding PAT: {0}")] Decoding(#[from] crate::models::ids::DecodingError), #[error("Invalid Authentication Credentials")] @@ -39,8 +40,6 @@ pub enum AuthenticationError { InvalidClientId, #[error("Invalid callback URL specified")] Url, - #[error("Error uploading user profile picture")] - FileHosting(#[from] FileHostingError), } impl actix_web::ResponseError for AuthenticationError { diff --git a/src/auth/pat.rs b/src/auth/pat.rs index 70850c011..4d47d0ff9 100644 --- a/src/auth/pat.rs +++ b/src/auth/pat.rs @@ -1,115 +1,115 @@ -use crate::auth::AuthenticationError; -use crate::database; -use crate::database::models::{DatabaseError, UserId}; -use crate::models::users::{self, Badges, RecipientType, RecipientWallet}; -use censor::Censor; -use chrono::{NaiveDateTime, Utc}; -use rand::Rng; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -pub struct PersonalAccessToken { - pub id: String, - pub name: Option, - pub access_token: Option, - pub scope: i64, - pub user_id: users::UserId, - pub expires_at: NaiveDateTime, -} -// Find database user from PAT token -// Separate to user_items as it may yet include further behaviour. -pub async fn get_user_from_pat<'a, E>( - access_token: &str, - executor: E, -) -> Result, AuthenticationError> -where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, -{ - let row = sqlx::query!( - " - SELECT pats.expires_at, - u.id, u.name, u.email, - u.avatar_url, u.username, u.bio, - u.created, u.role, u.badges, - u.balance, u.payout_wallet, u.payout_wallet_type, u.payout_address, - github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id - FROM pats LEFT OUTER JOIN users u ON pats.user_id = u.id - WHERE access_token = $1 - ", - access_token - ) - .fetch_optional(executor) - .await?; - if let Some(row) = row { - if row.expires_at < Utc::now().naive_utc() { - return Ok(None); - } - - return Ok(Some(database::models::User { - id: UserId(row.id), - name: row.name, - github_id: row.github_id, - discord_id: row.discord_id, - gitlab_id: row.gitlab_id, - google_id: row.google_id, - steam_id: row.steam_id, - microsoft_id: row.microsoft_id, - email: row.email, - avatar_url: row.avatar_url, - username: row.username, - bio: row.bio, - created: row.created, - role: row.role, - badges: Badges::from_bits(row.badges as u64).unwrap_or_default(), - balance: row.balance, - payout_wallet: row.payout_wallet.map(|x| RecipientWallet::from_string(&x)), - payout_wallet_type: row - .payout_wallet_type - .map(|x| RecipientType::from_string(&x)), - payout_address: row.payout_address, - })); - } - Ok(None) -} - -// Generate a new 128 char PAT token starting with 'modrinth_pat_' -pub async fn generate_pat( - con: &mut sqlx::Transaction<'_, sqlx::Postgres>, -) -> Result { - let mut rng = rand::thread_rng(); - let mut retry_count = 0; - let censor = Censor::Standard + Censor::Sex; - - // First generate the PAT token as a random 128 char string. This may include uppercase and lowercase and numbers only. - loop { - let mut access_token = String::with_capacity(63); - access_token.push_str("modrinth_pat_"); - for _ in 0..51 { - let c = rng.gen_range(0..62); - if c < 10 { - access_token.push(char::from_u32(c + 48).unwrap()); // 0-9 - } else if c < 36 { - access_token.push(char::from_u32(c + 55).unwrap()); // A-Z - } else { - access_token.push(char::from_u32(c + 61).unwrap()); // a-z - } - } - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM pats WHERE access_token=$1) - ", - access_token - ) - .fetch_one(&mut *con) - .await?; - - if !results.exists.unwrap_or(true) && !censor.check(&access_token) { - break Ok(access_token); - } - - retry_count += 1; - if retry_count > 15 { - return Err(DatabaseError::RandomId); - } - } -} +// use crate::auth::AuthenticationError; +// use crate::database; +// use crate::database::models::{DatabaseError, UserId}; +// use crate::models::users::{self, Badges, RecipientType, RecipientWallet}; +// use censor::Censor; +// use chrono::{NaiveDateTime, Utc}; +// use rand::Rng; +// use serde::{Deserialize, Serialize}; +// +// #[derive(Serialize, Deserialize)] +// pub struct PersonalAccessToken { +// pub id: String, +// pub name: Option, +// pub access_token: Option, +// pub scope: i64, +// pub user_id: users::UserId, +// pub expires_at: NaiveDateTime, +// } +// // Find database user from PAT token +// // Separate to user_items as it may yet include further behaviour. +// pub async fn get_user_from_pat<'a, E>( +// access_token: &str, +// executor: E, +// ) -> Result, AuthenticationError> +// where +// E: sqlx::Executor<'a, Database = sqlx::Postgres>, +// { +// let row = sqlx::query!( +// " +// SELECT pats.expires_at, +// u.id, u.name, u.email, +// u.avatar_url, u.username, u.bio, +// u.created, u.role, u.badges, +// u.balance, u.payout_wallet, u.payout_wallet_type, u.payout_address, +// github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id +// FROM pats LEFT OUTER JOIN users u ON pats.user_id = u.id +// WHERE access_token = $1 +// ", +// access_token +// ) +// .fetch_optional(executor) +// .await?; +// if let Some(row) = row { +// if row.expires_at < Utc::now().naive_utc() { +// return Ok(None); +// } +// +// return Ok(Some(database::models::User { +// id: UserId(row.id), +// name: row.name, +// github_id: row.github_id, +// discord_id: row.discord_id, +// gitlab_id: row.gitlab_id, +// google_id: row.google_id, +// steam_id: row.steam_id, +// microsoft_id: row.microsoft_id, +// email: row.email, +// avatar_url: row.avatar_url, +// username: row.username, +// bio: row.bio, +// created: row.created, +// role: row.role, +// badges: Badges::from_bits(row.badges as u64).unwrap_or_default(), +// balance: row.balance, +// payout_wallet: row.payout_wallet.map(|x| RecipientWallet::from_string(&x)), +// payout_wallet_type: row +// .payout_wallet_type +// .map(|x| RecipientType::from_string(&x)), +// payout_address: row.payout_address, +// })); +// } +// Ok(None) +// } +// +// // Generate a new 128 char PAT token starting with 'modrinth_pat_' +// pub async fn generate_pat( +// con: &mut sqlx::Transaction<'_, sqlx::Postgres>, +// ) -> Result { +// let mut rng = rand::thread_rng(); +// let mut retry_count = 0; +// let censor = Censor::Standard + Censor::Sex; +// +// // First generate the PAT token as a random 128 char string. This may include uppercase and lowercase and numbers only. +// loop { +// let mut access_token = String::with_capacity(63); +// access_token.push_str("modrinth_pat_"); +// for _ in 0..51 { +// let c = rng.gen_range(0..62); +// if c < 10 { +// access_token.push(char::from_u32(c + 48).unwrap()); // 0-9 +// } else if c < 36 { +// access_token.push(char::from_u32(c + 55).unwrap()); // A-Z +// } else { +// access_token.push(char::from_u32(c + 61).unwrap()); // a-z +// } +// } +// let results = sqlx::query!( +// " +// SELECT EXISTS(SELECT 1 FROM pats WHERE access_token=$1) +// ", +// access_token +// ) +// .fetch_one(&mut *con) +// .await?; +// +// if !results.exists.unwrap_or(true) && !censor.check(&access_token) { +// break Ok(access_token); +// } +// +// retry_count += 1; +// if retry_count > 15 { +// return Err(DatabaseError::RandomId); +// } +// } +// } diff --git a/src/auth/session.rs b/src/auth/session.rs index a2564982a..1d9512ede 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -1,19 +1,43 @@ -use crate::auth::AuthenticationError; -use crate::database::models::session_item::{Session, SessionBuilder}; +use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::session_item::Session as DBSession; +use crate::database::models::session_item::SessionBuilder; use crate::database::models::UserId; +use crate::models::sessions::Session; +use crate::queue::session::SessionQueue; +use crate::routes::ApiError; use crate::util::env::parse_var; -use actix_web::HttpRequest; +use actix_web::http::header::AUTHORIZATION; +use actix_web::web::{scope, Data, ServiceConfig}; +use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; +use chrono::Utc; use rand::distributions::Alphanumeric; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; +use sqlx::PgPool; use woothee::parser::Parser; -pub async fn issue_session( - req: HttpRequest, - user_id: UserId, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - redis: &deadpool_redis::Pool, -) -> Result { +pub fn config(cfg: &mut ServiceConfig) { + cfg.service( + scope("session") + .service(list) + .service(delete) + .service(refresh), + ); +} + +pub struct SessionMetadata { + pub city: Option, + pub country: Option, + pub ip: String, + + pub os: Option, + pub platform: Option, + pub user_agent: String, +} + +pub async fn get_session_metadata( + req: &HttpRequest, +) -> Result { let conn_info = req.connection_info().clone(); let ip_addr = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) { if let Some(header) = req.headers().get("CF-Connecting-IP") { @@ -45,6 +69,26 @@ pub async fn issue_session( None }; + Ok(SessionMetadata { + os: os.map(|x| x.0.to_string()), + platform: os.map(|x| x.1.to_string()), + city: city.map(|x| x.to_string()), + country: country.map(|x| x.to_string()), + ip: ip_addr + .ok_or_else(|| AuthenticationError::InvalidCredentials)? + .to_string(), + user_agent: user_agent.to_string(), + }) +} + +pub async fn issue_session( + req: HttpRequest, + user_id: UserId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &deadpool_redis::Pool, +) -> Result { + let metadata = get_session_metadata(&req).await?; + let session = ChaCha20Rng::from_entropy() .sample_iter(&Alphanumeric) .take(60) @@ -56,25 +100,118 @@ pub async fn issue_session( let id = SessionBuilder { session, user_id, - os: os.map(|x| x.0.to_string()), - platform: os.map(|x| x.1.to_string()), - city: city.map(|x| x.to_string()), - country: country.map(|x| x.to_string()), - ip: ip_addr - .ok_or_else(|| AuthenticationError::InvalidCredentials)? - .to_string(), - user_agent: user_agent.to_string(), + os: metadata.os, + platform: metadata.platform, + city: metadata.city, + country: metadata.country, + ip: metadata.ip, + user_agent: metadata.user_agent, } .insert(transaction) .await?; - let session = Session::get_id(id, &mut *transaction, redis) + let session = DBSession::get_id(id, &mut *transaction, redis) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; Ok(session) } -// TODO: List user sessions route -// TODO: Delete User Session Route / logout -// TODO: Refresh session route +#[get("list")] +pub async fn list( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; + + let session_ids = DBSession::get_user_sessions(current_user.id.into(), &**pool, &redis).await?; + let sessions = DBSession::get_many_ids(&session_ids, &**pool, &redis) + .await? + .into_iter() + .filter(|x| x.expires > Utc::now()) + .map(|x| Session::from(x, false)) + .collect::>(); + + Ok(HttpResponse::Ok().json(sessions)) +} + +#[delete("{id}")] +pub async fn delete( + info: web::Path<(String,)>, + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; + + let session = DBSession::get(info.into_inner().0, &**pool, &redis).await?; + + if let Some(session) = session { + if session.user_id != current_user.id.into() { + let mut transaction = pool.begin().await?; + DBSession::remove(session.id, &mut transaction).await?; + DBSession::clear_cache( + vec![( + Some(session.id), + Some(session.session), + Some(session.user_id), + )], + &redis, + ) + .await?; + transaction.commit().await?; + } + } + + Ok(HttpResponse::NoContent().body("")) +} + +#[post("refresh")] +pub async fn refresh( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; + let session = req + .headers() + .get(AUTHORIZATION) + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidCredentials))?; + + let session = DBSession::get(session, &**pool, &redis).await?; + + if let Some(session) = session { + if current_user.id != session.user_id.into() || session.refresh_expires < Utc::now() { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let mut transaction = pool.begin().await?; + + DBSession::remove(session.id, &mut transaction).await?; + let new_session = issue_session(req, session.user_id, &mut transaction, &redis).await?; + DBSession::clear_cache( + vec![( + Some(session.id), + Some(session.session), + Some(session.user_id), + )], + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(Session::from(new_session, true))) + } else { + Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )) + } +} diff --git a/src/auth/validate.rs b/src/auth/validate.rs index a6af3c1d5..b7e0785bb 100644 --- a/src/auth/validate.rs +++ b/src/auth/validate.rs @@ -1,29 +1,35 @@ use crate::auth::flows::AuthProvider; -use crate::auth::get_user_from_pat; +use crate::auth::session::get_session_metadata; use crate::auth::AuthenticationError; use crate::database::models::user_item; use crate::models::users::{Role, User, UserId, UserPayoutData}; -use actix_web::http::header::HeaderMap; +use crate::queue::session::SessionQueue; +use actix_web::HttpRequest; +use chrono::Utc; use reqwest::header::{HeaderValue, AUTHORIZATION}; pub async fn get_user_from_headers<'a, E>( - headers: &HeaderMap, + req: &HttpRequest, executor: E, redis: &deadpool_redis::Pool, + session_queue: &SessionQueue, ) -> Result 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 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)?; @@ -55,24 +61,33 @@ where } pub async fn get_user_record_from_bearer_token<'a, 'b, E>( + req: &HttpRequest, token: &str, executor: E, redis: &deadpool_redis::Pool, + session_queue: &SessionQueue, ) -> Result, AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { - let token: &str = token.trim_start_matches("Bearer "); - let possible_user = match token.split_once('_') { - Some(("modrinth", _)) => get_user_from_pat(token, executor).await?, + //Some(("modrinth", _)) => get_user_from_pat(token, executor).await?, Some(("mra", _)) => { let session = crate::database::models::session_item::Session::get(token, executor, redis) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - user_item::User::get_id(session.user_id, executor, redis).await? + if session.expires < Utc::now() { + return Err(AuthenticationError::InvalidCredentials); + } + + let user = user_item::User::get_id(session.user_id, executor, redis).await?; + + let metadata = get_session_metadata(req).await?; + session_queue.add(session.id, metadata).await; + + user } Some(("github", _)) | Some(("gho", _)) | Some(("ghp", _)) => { let user = AuthProvider::GitHub.get_user(token).await?; @@ -91,14 +106,15 @@ where } pub async fn check_is_moderator_from_headers<'a, 'b, E>( - headers: &HeaderMap, + req: &HttpRequest, executor: E, redis: &deadpool_redis::Pool, + session_queue: &SessionQueue, ) -> Result where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { - let user = get_user_from_headers(headers, executor, redis).await?; + let user = get_user_from_headers(req, executor, redis, session_queue).await?; if user.role.is_mod() { Ok(user) diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index a4b0618fa..d5e9abca9 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -83,13 +83,13 @@ generate_ids!( "SELECT EXISTS(SELECT 1 FROM states WHERE id=$1)", StateId ); -generate_ids!( - pub generate_pat_id, - PatId, - 8, - "SELECT EXISTS(SELECT 1 FROM pats WHERE id=$1)", - PatId -); +// generate_ids!( +// pub generate_pat_id, +// PatId, +// 8, +// "SELECT EXISTS(SELECT 1 FROM pats WHERE id=$1)", +// PatId +// ); generate_ids!( pub generate_user_id, diff --git a/src/database/models/session_item.rs b/src/database/models/session_item.rs index 78b3f757b..cfe607278 100644 --- a/src/database/models/session_item.rs +++ b/src/database/models/session_item.rs @@ -10,8 +10,6 @@ const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids"; const SESSIONS_USERS_NAMESPACE: &str = "sessions_users"; const DEFAULT_EXPIRY: i64 = 1800; // 30 minutes -// TODO: Manage sessions cache + clear cache when needed - pub struct SessionBuilder { pub session: String, pub user_id: UserId, @@ -293,10 +291,33 @@ impl Session { Ok(db_sessions) } + pub async fn clear_cache( + clear_sessions: Vec<(Option, Option, Option)>, + redis: &deadpool_redis::Pool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.get().await?; + let mut cmd = cmd("DEL"); + + for (id, session, user_id) in clear_sessions { + if let Some(id) = id { + cmd.arg(format!("{}:{}", SESSIONS_NAMESPACE, id.0)); + } + if let Some(session) = session { + cmd.arg(format!("{}:{}", SESSIONS_IDS_NAMESPACE, session)); + } + if let Some(user_id) = user_id { + cmd.arg(format!("{}:{}", SESSIONS_USERS_NAMESPACE, user_id.0)); + } + } + + cmd.query_async::<_, ()>(&mut redis).await?; + + Ok(()) + } + pub async fn remove( id: SessionId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - // redis: &deadpool_redis::Pool, ) -> Result, sqlx::error::Error> { sqlx::query!( " diff --git a/src/database/models/team_item.rs b/src/database/models/team_item.rs index 740bb39be..c3ad00211 100644 --- a/src/database/models/team_item.rs +++ b/src/database/models/team_item.rs @@ -1,7 +1,5 @@ use super::ids::*; -use crate::database::models::User; use crate::models::teams::Permissions; -use crate::models::users::Badges; use itertools::Itertools; use redis::cmd; use rust_decimal::Decimal; @@ -83,6 +81,7 @@ pub struct Team { } /// A member of a team +#[derive(Deserialize, Serialize)] pub struct TeamMember { pub id: TeamMemberId, pub team_id: TeamId, @@ -95,27 +94,13 @@ pub struct TeamMember { pub ordering: i64, } -/// A member of a team -#[derive(Deserialize, Serialize)] -pub struct QueryTeamMember { - pub id: TeamMemberId, - pub team_id: TeamId, - /// The user associated with the member - pub user: User, - pub role: String, - pub permissions: Permissions, - pub accepted: bool, - pub payouts_split: Decimal, - pub ordering: i64, -} - impl TeamMember { // Lists the full members of a team pub async fn get_from_team_full<'a, 'b, E>( id: TeamId, executor: E, redis: &deadpool_redis::Pool, - ) -> Result, super::DatabaseError> + ) -> Result, super::DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { @@ -126,7 +111,7 @@ impl TeamMember { team_ids: &[TeamId], exec: E, redis: &deadpool_redis::Pool, - ) -> Result, super::DatabaseError> + ) -> Result, super::DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { @@ -155,7 +140,7 @@ impl TeamMember { for team_raw in teams { if let Some(mut team) = team_raw .clone() - .and_then(|x| serde_json::from_str::>(&x).ok()) + .and_then(|x| serde_json::from_str::>(&x).ok()) { if let Some(team_id) = team.first().map(|x| x.team_id) { team_ids_parsed.retain(|x| &team_id.0 != x); @@ -167,14 +152,11 @@ impl TeamMember { } if !team_ids_parsed.is_empty() { - let teams: Vec = sqlx::query!( + let teams: Vec = sqlx::query!( " SELECT tm.id id, tm.team_id team_id, tm.role member_role, tm.permissions permissions, tm.accepted accepted, tm.payouts_split payouts_split, tm.ordering, - u.id user_id, u.name user_name, - u.avatar_url avatar_url, u.username username, u.bio bio, - u.created created, u.role user_role, u.badges badges + tm.user_id user_id FROM team_members tm - INNER JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ANY($1) ORDER BY tm.team_id, tm.ordering ", @@ -183,39 +165,19 @@ impl TeamMember { .fetch_many(exec) .try_filter_map(|e| async { Ok(e.right().map(|m| - QueryTeamMember { + TeamMember { id: TeamMemberId(m.id), team_id: TeamId(m.team_id), role: m.member_role, permissions: Permissions::from_bits(m.permissions as u64).unwrap_or_default(), accepted: m.accepted, - user: User { - id: UserId(m.user_id), - github_id: None, - discord_id: None, - gitlab_id: None, - google_id: None, - steam_id: None, - name: m.user_name, - email: None, - avatar_url: m.avatar_url, - username: m.username, - bio: m.bio, - created: m.created, - role: m.user_role, - badges: Badges::from_bits(m.badges as u64).unwrap_or_default(), - balance: Decimal::ZERO, - payout_wallet: None, - payout_wallet_type: None, - payout_address: None, - microsoft_id: None, - }, + user_id: UserId(m.user_id), payouts_split: m.payouts_split, ordering: m.ordering, } )) }) - .try_collect::>() + .try_collect::>() .await?; for (id, members) in &teams.into_iter().group_by(|x| x.team_id) { diff --git a/src/database/models/user_item.rs b/src/database/models/user_item.rs index 71b8ad2f2..af9cb55d5 100644 --- a/src/database/models/user_item.rs +++ b/src/database/models/user_item.rs @@ -12,7 +12,7 @@ const USER_USERNAMES_NAMESPACE: &str = "users_usernames"; // const USERS_PROJECTS_NAMESPACE: &str = "users_projects"; const DEFAULT_EXPIRY: i64 = 1800; // 30 minutes -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone, Debug)] pub struct User { pub id: UserId, @@ -22,10 +22,12 @@ pub struct User { pub google_id: Option, pub steam_id: Option, pub microsoft_id: Option, + pub password: Option, pub username: String, pub name: Option, pub email: Option, + pub email_verified: bool, pub avatar_url: Option, pub bio: Option, pub created: DateTime, @@ -47,12 +49,14 @@ impl User { INSERT INTO users ( id, username, name, email, avatar_url, bio, created, - github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id + github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, + email_verified, password ) VALUES ( $1, $2, $3, $4, $5, $6, $7, - $8, $9, $10, $11, $12, $13 + $8, $9, $10, $11, $12, $13, + $14, $15 ) ", self.id as UserId, @@ -68,6 +72,8 @@ impl User { self.google_id, self.steam_id, self.microsoft_id, + self.email_verified, + self.password, ) .execute(&mut *transaction) .await?; @@ -197,9 +203,10 @@ impl User { avatar_url, username, bio, created, role, badges, balance, payout_wallet, payout_wallet_type, payout_address, - github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id + github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, + email_verified, password FROM users - WHERE id = ANY($1) OR username = ANY($2) + WHERE id = ANY($1) OR LOWER(username) = ANY($2) ", &user_ids_parsed, &remaining_strings @@ -219,6 +226,7 @@ impl User { microsoft_id: u.microsoft_id, name: u.name, email: u.email, + email_verified: u.email_verified, avatar_url: u.avatar_url, username: u.username, bio: u.bio, @@ -231,6 +239,7 @@ impl User { .payout_wallet_type .map(|x| RecipientType::from_string(&x)), payout_address: u.payout_address, + password: u.password, })) }) .try_collect::>() diff --git a/src/main.rs b/src/main.rs index 9a52a00ce..ede962874 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use crate::file_hosting::S3Host; use crate::queue::download::DownloadQueue; use crate::queue::payouts::PayoutsQueue; +use crate::queue::session::SessionQueue; use crate::ratelimit::errors::ARError; use crate::ratelimit::memory::{MemoryStore, MemoryStoreActor}; use crate::ratelimit::middleware::RateLimiter; @@ -269,7 +270,7 @@ async fn main() -> std::io::Result<()> { scheduler::schedule_versions(&mut scheduler, pool.clone()); - let download_queue = Arc::new(DownloadQueue::new()); + let download_queue = web::Data::new(DownloadQueue::new()); let pool_ref = pool.clone(); let download_queue_ref = download_queue.clone(); @@ -287,11 +288,31 @@ async fn main() -> std::io::Result<()> { } }); + let session_queue = web::Data::new(SessionQueue::new()); + + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + let session_queue_ref = session_queue.clone(); + scheduler.run(std::time::Duration::from_secs(60), move || { + let pool_ref = pool_ref.clone(); + let redis_ref = redis_ref.clone(); + let session_queue_ref = session_queue_ref.clone(); + + async move { + info!("Indexing sessions queue"); + let result = session_queue_ref.index(&pool_ref, &redis_ref).await; + if let Err(e) = result { + warn!("Indexing sessions queue failed: {:?}", e); + } + info!("Done indexing sessions queue"); + } + }); + let ip_salt = Pepper { pepper: models::ids::Base62Id(models::ids::random_base62(11)).to_string(), }; - let payouts_queue = Arc::new(Mutex::new(PayoutsQueue::new())); + let payouts_queue = web::Data::new(Mutex::new(PayoutsQueue::new())); let store = MemoryStore::new(); @@ -354,14 +375,14 @@ async fn main() -> std::io::Result<()> { .app_data(web::Data::new(pool.clone())) .app_data(web::Data::new(file_host.clone())) .app_data(web::Data::new(search_config.clone())) - .app_data(web::Data::new(download_queue.clone())) - .app_data(web::Data::new(payouts_queue.clone())) + .app_data(download_queue.clone()) + .app_data(session_queue.clone()) + .app_data(payouts_queue.clone()) .app_data(web::Data::new(ip_salt.clone())) .wrap(sentry_actix::Sentry::new()) .configure(routes::root_config) .configure(routes::v2::config) .configure(routes::v3::config) - .configure(auth::config) .default_service(web::get().to(routes::not_found)) }) .bind(dotenvy::var("BIND_ADDR").unwrap())? @@ -441,6 +462,17 @@ fn check_env_vars() -> bool { failed |= check_var::("GITHUB_CLIENT_ID"); failed |= check_var::("GITHUB_CLIENT_SECRET"); + failed |= check_var::("GITLAB_CLIENT_ID"); + failed |= check_var::("GITLAB_CLIENT_SECRET"); + failed |= check_var::("DISCORD_CLIENT_ID"); + failed |= check_var::("DISCORD_CLIENT_SECRET"); + failed |= check_var::("MICROSOFT_CLIENT_ID"); + failed |= check_var::("MICROSOFT_CLIENT_SECRET"); + failed |= check_var::("GOOGLE_CLIENT_ID"); + failed |= check_var::("GOOGLE_CLIENT_SECRET"); + failed |= check_var::("STEAM_API_KEY"); + + failed |= check_var::("TURNSTILE_SECRET"); failed |= check_var::("ARIADNE_ADMIN_KEY"); failed |= check_var::("ARIADNE_URL"); diff --git a/src/models/sessions.rs b/src/models/sessions.rs index 702a7acdf..9d30a15a6 100644 --- a/src/models/sessions.rs +++ b/src/models/sessions.rs @@ -11,7 +11,7 @@ pub struct SessionId(pub u64); #[derive(Serialize, Deserialize, Clone)] pub struct Session { pub id: SessionId, - pub session: String, + pub session: Option, pub user_id: UserId, pub created: DateTime, @@ -27,3 +27,30 @@ pub struct Session { pub country: Option, pub ip: String, } + +impl Session { + pub fn from( + data: crate::database::models::session_item::Session, + include_session: bool, + ) -> Self { + Session { + id: data.id.into(), + session: if include_session { + Some(data.session) + } else { + None + }, + user_id: data.user_id.into(), + created: data.created, + last_login: data.last_login, + expires: data.expires, + refresh_expires: data.refresh_expires, + os: data.os, + platform: data.platform, + user_agent: data.user_agent, + city: data.city, + country: data.country, + ip: data.ip, + } + } +} diff --git a/src/models/teams.rs b/src/models/teams.rs index 991feefa0..fa7d93a9c 100644 --- a/src/models/teams.rs +++ b/src/models/teams.rs @@ -1,5 +1,4 @@ use super::ids::Base62Id; -use crate::database::models::team_item::QueryTeamMember; use crate::models::users::User; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -70,8 +69,12 @@ pub struct TeamMember { } impl TeamMember { - pub fn from(data: QueryTeamMember, override_permissions: bool) -> Self { - let user: User = data.user.into(); + pub fn from( + data: crate::database::models::team_item::TeamMember, + user: crate::database::models::User, + override_permissions: bool, + ) -> Self { + let user: User = user.into(); Self { team_id: data.team_id.into(), diff --git a/src/models/users.rs b/src/models/users.rs index b702b22b3..b138b3318 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -62,7 +62,7 @@ pub struct UserPayoutData { pub payout_address: Option, } -#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)] #[serde(rename_all = "snake_case")] pub enum RecipientType { Email, @@ -94,7 +94,7 @@ impl RecipientType { } } -#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)] #[serde(rename_all = "snake_case")] pub enum RecipientWallet { Venmo, diff --git a/src/queue/mod.rs b/src/queue/mod.rs index f2d7abb4c..5c8237cf9 100644 --- a/src/queue/mod.rs +++ b/src/queue/mod.rs @@ -1,2 +1,3 @@ pub mod download; pub mod payouts; +pub mod session; diff --git a/src/queue/session.rs b/src/queue/session.rs new file mode 100644 index 000000000..c18078a9a --- /dev/null +++ b/src/queue/session.rs @@ -0,0 +1,91 @@ +use crate::auth::session::SessionMetadata; +use crate::database::models::session_item::Session; +use crate::database::models::{DatabaseError, SessionId, UserId}; +use chrono::Utc; +use sqlx::PgPool; +use tokio::sync::Mutex; + +pub struct SessionQueue { + queue: Mutex>, +} + +// Batches session accessing transactions every 30 seconds +impl SessionQueue { + pub fn new() -> Self { + SessionQueue { + queue: Mutex::new(Vec::with_capacity(1000)), + } + } + pub async fn add(&self, id: SessionId, metadata: SessionMetadata) { + self.queue.lock().await.push((id, metadata)); + } + + pub async fn take(&self) -> Vec<(SessionId, SessionMetadata)> { + let mut queue = self.queue.lock().await; + let len = queue.len(); + + std::mem::replace(&mut queue, Vec::with_capacity(len)) + } + + pub async fn index( + &self, + pool: &PgPool, + redis: &deadpool_redis::Pool, + ) -> Result<(), DatabaseError> { + let queue = self.take().await; + + if !queue.is_empty() { + let mut transaction = pool.begin().await?; + let mut clear_cache_sessions = Vec::new(); + + for (id, metadata) in queue { + clear_cache_sessions.push((Some(id), None, None)); + + sqlx::query!( + " + UPDATE sessions + SET last_login = $2, city = $3, country = $4, ip = $5, os = $6, platform = $7, user_agent = $8 + WHERE (id = $1) + ", + id as SessionId, + Utc::now(), + metadata.city, + metadata.country, + metadata.ip, + metadata.os, + metadata.platform, + metadata.user_agent, + ) + .execute(&mut *transaction) + .await?; + } + + use futures::TryStreamExt; + let expired_ids = sqlx::query!( + " + SELECT id, session, user_id + FROM sessions + WHERE refresh_expires >= NOW() + " + ) + .fetch_many(&mut *transaction) + .try_filter_map(|e| async { + Ok(e.right() + .map(|x| (SessionId(x.id), x.session, UserId(x.user_id)))) + }) + .try_collect::>() + .await?; + + for (id, session, user_id) in expired_ids { + clear_cache_sessions.push((Some(id), Some(session), Some(user_id))); + Session::remove(id, &mut transaction).await?; + } + + Session::clear_cache(clear_cache_sessions, redis).await?; + + transaction.commit().await?; + } + + Ok(()) + } +} diff --git a/src/routes/maven.rs b/src/routes/maven.rs index 708a7aa87..465073377 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -2,6 +2,7 @@ use crate::auth::{get_user_from_headers, is_authorized_version}; use crate::database::models::project_item::QueryProject; use crate::database::models::version_item::{QueryFile, QueryVersion}; use crate::models::projects::{ProjectId, VersionId}; +use crate::queue::session::SessionQueue; use crate::routes::ApiError; use crate::{auth::is_authorized, database}; use actix_web::{get, route, web, HttpRequest, HttpResponse}; @@ -67,6 +68,7 @@ pub async fn maven_metadata( params: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let project_id = params.into_inner().0; let project_data = database::models::Project::get(&project_id, &**pool, &redis).await?; @@ -77,7 +79,7 @@ pub async fn maven_metadata( return Ok(HttpResponse::NotFound().body("")); }; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -188,6 +190,7 @@ pub async fn version_file( params: web::Path<(String, String, String)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let (project_id, vnum, file) = params.into_inner(); let project_data = database::models::Project::get(&project_id, &**pool, &redis).await?; @@ -198,7 +201,7 @@ pub async fn version_file( return Ok(HttpResponse::NotFound().body("")); }; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -271,6 +274,7 @@ pub async fn version_file_sha1( params: web::Path<(String, String, String)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let (project_id, vnum, file) = params.into_inner(); let project_data = database::models::Project::get(&project_id, &**pool, &redis).await?; @@ -281,7 +285,7 @@ pub async fn version_file_sha1( return Ok(HttpResponse::NotFound().body("")); }; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -328,6 +332,7 @@ pub async fn version_file_sha512( params: web::Path<(String, String, String)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let (project_id, vnum, file) = params.into_inner(); let project_data = database::models::Project::get(&project_id, &**pool, &redis).await?; @@ -338,7 +343,7 @@ pub async fn version_file_sha512( return Ok(HttpResponse::NotFound().body("")); }; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 875c4b094..2654a3131 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -61,11 +61,17 @@ pub enum ApiError { #[error("Payments Error: {0}")] Payments(String), #[error("Discord Error: {0}")] - DiscordError(String), + Discord(String), + #[error("Captcha Error. Try resubmitting the form.")] + Turnstile, #[error("Error while decoding Base62: {0}")] Decoding(#[from] crate::models::ids::DecodingError), #[error("Image Parsing Error: {0}")] - ImageError(#[from] image::ImageError), + ImageParse(#[from] image::ImageError), + #[error("Password Hashing Error: {0}")] + PasswordHashing(#[from] argon2::password_hash::Error), + #[error("Password strength checking error: {0}")] + PasswordStrengthCheck(#[from] zxcvbn::ZxcvbnError), } impl actix_web::ResponseError for ApiError { @@ -85,9 +91,12 @@ impl actix_web::ResponseError for ApiError { ApiError::Validation(..) => StatusCode::BAD_REQUEST, ApiError::Analytics(..) => StatusCode::FAILED_DEPENDENCY, ApiError::Payments(..) => StatusCode::FAILED_DEPENDENCY, - ApiError::DiscordError(..) => StatusCode::FAILED_DEPENDENCY, + ApiError::Discord(..) => StatusCode::FAILED_DEPENDENCY, + ApiError::Turnstile => StatusCode::BAD_REQUEST, ApiError::Decoding(..) => StatusCode::BAD_REQUEST, - ApiError::ImageError(..) => StatusCode::BAD_REQUEST, + ApiError::ImageParse(..) => StatusCode::BAD_REQUEST, + ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::PasswordStrengthCheck(..) => StatusCode::BAD_REQUEST, } } @@ -108,9 +117,12 @@ impl actix_web::ResponseError for ApiError { ApiError::Validation(..) => "invalid_input", ApiError::Analytics(..) => "analytics_error", ApiError::Payments(..) => "payments_error", - ApiError::DiscordError(..) => "discord_error", + ApiError::Discord(..) => "discord_error", + ApiError::Turnstile => "turnstile_error", ApiError::Decoding(..) => "decoding_error", - ApiError::ImageError(..) => "invalid_image", + ApiError::ImageParse(..) => "invalid_image", + ApiError::PasswordHashing(..) => "password_hashing_error", + ApiError::PasswordStrengthCheck(..) => "strength_check_error", }, description: &self.to_string(), }) diff --git a/src/routes/updates.rs b/src/routes/updates.rs index 752dd6e6e..d127f7403 100644 --- a/src/routes/updates.rs +++ b/src/routes/updates.rs @@ -7,6 +7,7 @@ use sqlx::PgPool; use crate::auth::{filter_authorized_versions, get_user_from_headers, is_authorized}; use crate::database; use crate::models::projects::VersionType; +use crate::queue::session::SessionQueue; use super::ApiError; @@ -20,6 +21,7 @@ pub async fn forge_updates( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { const ERROR: &str = "The specified project does not exist!"; @@ -29,7 +31,7 @@ pub async fn forge_updates( .await? .ok_or_else(|| ApiError::InvalidInput(ERROR.to_string()))?; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); diff --git a/src/routes/v2/admin.rs b/src/routes/v2/admin.rs index de9c08757..73e1df991 100644 --- a/src/routes/v2/admin.rs +++ b/src/routes/v2/admin.rs @@ -11,7 +11,6 @@ use serde::Deserialize; use serde_json::json; use sqlx::PgPool; use std::collections::HashMap; -use std::sync::Arc; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( @@ -36,7 +35,7 @@ pub struct DownloadBody { pub async fn count_download( pool: web::Data, download_body: web::Json, - download_queue: web::Data>, + download_queue: web::Data, ) -> Result { let project_id: crate::database::models::ids::ProjectId = download_body.project_id.into(); diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index 787c7d1ce..96848d68f 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -20,26 +20,20 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service( actix_web::web::scope("v2") .configure(admin::config) - .configure(crate::auth::config) + .configure(crate::auth::session::config) + .configure(crate::auth::flows::config) .configure(moderation::config) .configure(notifications::config) - .configure(pats::config) + //.configure(pats::config) .configure(project_creation::config) - // SHOULD CACHE .configure(projects::config) .configure(reports::config) - // should cache in future .configure(statistics::config) - // should cache in future .configure(tags::config) - // should cache .configure(teams::config) .configure(threads::config) - // should cache .configure(users::config) - // should cache in future .configure(version_file::config) - // SHOULD CACHE .configure(versions::config), ); } diff --git a/src/routes/v2/moderation.rs b/src/routes/v2/moderation.rs index 99e431ef7..b0f6da1d1 100644 --- a/src/routes/v2/moderation.rs +++ b/src/routes/v2/moderation.rs @@ -2,6 +2,7 @@ use super::ApiError; use crate::auth::check_is_moderator_from_headers; use crate::database; use crate::models::projects::ProjectStatus; +use crate::queue::session::SessionQueue; use actix_web::{get, web, HttpRequest, HttpResponse}; use serde::Deserialize; use sqlx::PgPool; @@ -26,8 +27,9 @@ pub async fn get_projects( pool: web::Data, redis: web::Data, count: web::Query, + session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers(req.headers(), &**pool, &redis).await?; + check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; use futures::stream::TryStreamExt; diff --git a/src/routes/v2/notifications.rs b/src/routes/v2/notifications.rs index dcaa5ed21..4bc23c79d 100644 --- a/src/routes/v2/notifications.rs +++ b/src/routes/v2/notifications.rs @@ -2,6 +2,7 @@ use crate::auth::get_user_from_headers; use crate::database; use crate::models::ids::NotificationId; use crate::models::notifications::Notification; +use crate::queue::session::SessionQueue; use crate::routes::ApiError; use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; @@ -31,8 +32,9 @@ pub async fn notifications_get( web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; use database::models::notification_item::Notification as DBNotification; use database::models::NotificationId as DBNotificationId; @@ -62,8 +64,9 @@ pub async fn notification_get( info: web::Path<(NotificationId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id = info.into_inner().0; @@ -87,8 +90,9 @@ pub async fn notification_read( info: web::Path<(NotificationId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id = info.into_inner().0; @@ -121,8 +125,9 @@ pub async fn notification_delete( info: web::Path<(NotificationId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id = info.into_inner().0; @@ -155,8 +160,9 @@ pub async fn notifications_read( web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let notification_ids = serde_json::from_str::>(&ids.ids)? .into_iter() @@ -191,8 +197,9 @@ pub async fn notifications_delete( web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let notification_ids = serde_json::from_str::>(&ids.ids)? .into_iter() diff --git a/src/routes/v2/pats.rs b/src/routes/v2/pats.rs index 040dfc6c5..2fb49be1b 100644 --- a/src/routes/v2/pats.rs +++ b/src/routes/v2/pats.rs @@ -1,244 +1,249 @@ -/*! -Current edition of Ory kratos does not support PAT access of data, so this module is how we allow for PAT authentication. - - -Just as a summary: Don't implement this flow in your application! -*/ - -use crate::database; -use crate::database::models::generate_pat_id; -use crate::models::ids::base62_impl::{parse_base62, to_base62}; - -use crate::auth::get_user_from_headers; -use crate::auth::{generate_pat, PersonalAccessToken}; -use crate::models::users::UserId; -use crate::routes::ApiError; - -use actix_web::web::{self, Data, Query}; -use actix_web::{delete, get, patch, post, HttpRequest, HttpResponse}; -use chrono::{Duration, Utc}; - -use serde::Deserialize; -use sqlx::postgres::PgPool; - -pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(get_pats); - cfg.service(create_pat); - cfg.service(edit_pat); - cfg.service(delete_pat); -} - -#[derive(Deserialize)] -pub struct CreatePersonalAccessToken { - pub scope: i64, // todo: should be a vec of enum - pub name: Option, - pub expire_in_days: i64, // resets expiry to expire_in_days days from now -} - -#[derive(Deserialize)] -pub struct ModifyPersonalAccessToken { - #[serde(default, with = "::serde_with::rust::double_option")] - pub name: Option>, - pub expire_in_days: Option, // resets expiry to expire_in_days days from now -} - -// GET /pat -// Get all personal access tokens for the given user. Minos/Kratos cookie must be attached for it to work. -// Does not return the actual access token, only the ID + metadata. -#[get("pat")] -pub async fn get_pats( - req: HttpRequest, - pool: Data, - redis: Data, -) -> Result { - let user: crate::models::users::User = - get_user_from_headers(req.headers(), &**pool, &redis).await?; - let db_user_id: database::models::UserId = database::models::UserId::from(user.id); - - let pats = sqlx::query!( - " - SELECT id, name, user_id, scope, expires_at - FROM pats - WHERE user_id = $1 - ", - db_user_id.0 - ) - .fetch_all(&**pool) - .await?; - - let pats = pats - .into_iter() - .map(|pat| PersonalAccessToken { - id: to_base62(pat.id as u64), - scope: pat.scope, - name: pat.name, - expires_at: pat.expires_at, - access_token: None, - user_id: UserId(pat.user_id as u64), - }) - .collect::>(); - - Ok(HttpResponse::Ok().json(pats)) -} - -// POST /pat -// Create a new personal access token for the given user. Minos/Kratos cookie must be attached for it to work. -// All PAT tokens are base62 encoded, and are prefixed with "modrinth_pat_" -#[post("pat")] -pub async fn create_pat( - req: HttpRequest, - Query(info): Query, // callback url - pool: Data, - redis: web::Data, -) -> Result { - let user: crate::models::users::User = - get_user_from_headers(req.headers(), &**pool, &redis).await?; - let db_user_id: database::models::UserId = database::models::UserId::from(user.id); - - let mut transaction: sqlx::Transaction = pool.begin().await?; - - let pat = generate_pat_id(&mut transaction).await?; - let access_token = generate_pat(&mut transaction).await?; - let expiry = Utc::now().naive_utc() + Duration::days(info.expire_in_days); - if info.expire_in_days <= 0 { - return Err(ApiError::InvalidInput( - "'expire_in_days' must be greater than 0".to_string(), - )); - } - - sqlx::query!( - " - INSERT INTO pats (id, name, access_token, user_id, scope, expires_at) - VALUES ($1, $2, $3, $4, $5, $6) - ", - pat.0, - info.name, - access_token, - db_user_id.0, - info.scope, - expiry - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::Ok().json(PersonalAccessToken { - id: to_base62(pat.0 as u64), - access_token: Some(access_token), - name: info.name, - scope: info.scope, - user_id: user.id, - expires_at: expiry, - })) -} - -// PATCH /pat/(id) -// Edit an access token of id "id" for the given user. -// 'None' will mean not edited. Minos/Kratos cookie or PAT must be attached for it to work. -#[patch("pat/{id}")] -pub async fn edit_pat( - req: HttpRequest, - id: web::Path, - Query(info): Query, // callback url - pool: Data, - redis: web::Data, -) -> Result { - let user: crate::models::users::User = - get_user_from_headers(req.headers(), &**pool, &redis).await?; - let pat_id = database::models::PatId(parse_base62(&id)? as i64); - let db_user_id: database::models::UserId = database::models::UserId::from(user.id); - - if let Some(expire_in_days) = info.expire_in_days { - if expire_in_days <= 0 { - return Err(ApiError::InvalidInput( - "'expire_in_days' must be greater than 0".to_string(), - )); - } - } - - // Get the singular PAT and user combination (failing immediately if it doesn't exist) - let mut transaction = pool.begin().await?; - let row = sqlx::query!( - " - SELECT id, name, scope, user_id, expires_at FROM pats - WHERE id = $1 AND user_id = $2 - ", - pat_id.0, - db_user_id.0 // included for safety - ) - .fetch_one(&**pool) - .await?; - - let pat = PersonalAccessToken { - id: to_base62(row.id as u64), - access_token: None, - user_id: UserId::from(db_user_id), - name: info.name.unwrap_or(row.name), - scope: row.scope, - expires_at: info - .expire_in_days - .map(|d| Utc::now().naive_utc() + Duration::days(d)) - .unwrap_or(row.expires_at), - }; - - sqlx::query!( - " - UPDATE pats SET - name = $1, - expires_at = $2 - WHERE id = $3 - ", - pat.name, - pat.expires_at, - parse_base62(&pat.id)? as i64 - ) - .execute(&mut *transaction) - .await?; - transaction.commit().await?; - - Ok(HttpResponse::Ok().json(pat)) -} - -// DELETE /pat -// Delete a personal access token for the given user. Minos/Kratos cookie must be attached for it to work. -#[delete("pat/{id}")] -pub async fn delete_pat( - req: HttpRequest, - id: web::Path, - pool: Data, - redis: web::Data, -) -> Result { - let user: crate::models::users::User = - get_user_from_headers(req.headers(), &**pool, &redis).await?; - let pat_id = database::models::PatId(parse_base62(&id)? as i64); - let db_user_id: database::models::UserId = database::models::UserId::from(user.id); - - // Get the singular PAT and user combination (failing immediately if it doesn't exist) - // This is to prevent users from deleting other users' PATs - let pat_id = sqlx::query!( - " - SELECT id FROM pats - WHERE id = $1 AND user_id = $2 - ", - pat_id.0, - db_user_id.0 - ) - .fetch_one(&**pool) - .await? - .id; - - let mut transaction = pool.begin().await?; - sqlx::query!( - " - DELETE FROM pats - WHERE id = $1 - ", - pat_id, - ) - .execute(&mut *transaction) - .await?; - transaction.commit().await?; - - Ok(HttpResponse::Ok().finish()) -} +// /*! +// Current edition of Ory kratos does not support PAT access of data, so this module is how we allow for PAT authentication. +// +// +// Just as a summary: Don't implement this flow in your application! +// */ +// +// use crate::database; +// use crate::database::models::generate_pat_id; +// use crate::models::ids::base62_impl::{parse_base62, to_base62}; +// +// use crate::auth::get_user_from_headers; +// use crate::auth::{generate_pat, PersonalAccessToken}; +// use crate::models::users::UserId; +// use crate::routes::ApiError; +// +// use actix_web::web::{self, Data, Query}; +// use actix_web::{delete, get, patch, post, HttpRequest, HttpResponse}; +// use chrono::{Duration, Utc}; +// +// use crate::queue::session::SessionQueue; +// use serde::Deserialize; +// use sqlx::postgres::PgPool; +// +// pub fn config(cfg: &mut web::ServiceConfig) { +// cfg.service(get_pats); +// cfg.service(create_pat); +// cfg.service(edit_pat); +// cfg.service(delete_pat); +// } +// +// #[derive(Deserialize)] +// pub struct CreatePersonalAccessToken { +// pub scope: i64, // todo: should be a vec of enum +// pub name: Option, +// pub expire_in_days: i64, // resets expiry to expire_in_days days from now +// } +// +// #[derive(Deserialize)] +// pub struct ModifyPersonalAccessToken { +// #[serde(default, with = "::serde_with::rust::double_option")] +// pub name: Option>, +// pub expire_in_days: Option, // resets expiry to expire_in_days days from now +// } +// +// // GET /pat +// // Get all personal access tokens for the given user. Minos/Kratos cookie must be attached for it to work. +// // Does not return the actual access token, only the ID + metadata. +// #[get("pat")] +// pub async fn get_pats( +// req: HttpRequest, +// pool: Data, +// redis: Data, +// session_queue: web::Data, +// ) -> Result { +// let user: crate::models::users::User = +// get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; +// let db_user_id: database::models::UserId = database::models::UserId::from(user.id); +// +// let pats = sqlx::query!( +// " +// SELECT id, name, user_id, scope, expires_at +// FROM pats +// WHERE user_id = $1 +// ", +// db_user_id.0 +// ) +// .fetch_all(&**pool) +// .await?; +// +// let pats = pats +// .into_iter() +// .map(|pat| PersonalAccessToken { +// id: to_base62(pat.id as u64), +// scope: pat.scope, +// name: pat.name, +// expires_at: pat.expires_at, +// access_token: None, +// user_id: UserId(pat.user_id as u64), +// }) +// .collect::>(); +// +// Ok(HttpResponse::Ok().json(pats)) +// } +// +// // POST /pat +// // Create a new personal access token for the given user. Minos/Kratos cookie must be attached for it to work. +// // All PAT tokens are base62 encoded, and are prefixed with "modrinth_pat_" +// #[post("pat")] +// pub async fn create_pat( +// req: HttpRequest, +// Query(info): Query, // callback url +// pool: Data, +// redis: web::Data, +// session_queue: web::Data, +// ) -> Result { +// let user: crate::models::users::User = +// get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; +// let db_user_id: database::models::UserId = database::models::UserId::from(user.id); +// +// let mut transaction: sqlx::Transaction = pool.begin().await?; +// +// let pat = generate_pat_id(&mut transaction).await?; +// let access_token = generate_pat(&mut transaction).await?; +// let expiry = Utc::now().naive_utc() + Duration::days(info.expire_in_days); +// if info.expire_in_days <= 0 { +// return Err(ApiError::InvalidInput( +// "'expire_in_days' must be greater than 0".to_string(), +// )); +// } +// +// sqlx::query!( +// " +// INSERT INTO pats (id, name, access_token, user_id, scope, expires_at) +// VALUES ($1, $2, $3, $4, $5, $6) +// ", +// pat.0, +// info.name, +// access_token, +// db_user_id.0, +// info.scope, +// expiry +// ) +// .execute(&mut *transaction) +// .await?; +// +// transaction.commit().await?; +// +// Ok(HttpResponse::Ok().json(PersonalAccessToken { +// id: to_base62(pat.0 as u64), +// access_token: Some(access_token), +// name: info.name, +// scope: info.scope, +// user_id: user.id, +// expires_at: expiry, +// })) +// } +// +// // PATCH /pat/(id) +// // Edit an access token of id "id" for the given user. +// // 'None' will mean not edited. Minos/Kratos cookie or PAT must be attached for it to work. +// #[patch("pat/{id}")] +// pub async fn edit_pat( +// req: HttpRequest, +// id: web::Path, +// Query(info): Query, // callback url +// pool: Data, +// redis: web::Data, +// session_queue: web::Data, +// ) -> Result { +// let user: crate::models::users::User = +// get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; +// let pat_id = database::models::PatId(parse_base62(&id)? as i64); +// let db_user_id: database::models::UserId = database::models::UserId::from(user.id); +// +// if let Some(expire_in_days) = info.expire_in_days { +// if expire_in_days <= 0 { +// return Err(ApiError::InvalidInput( +// "'expire_in_days' must be greater than 0".to_string(), +// )); +// } +// } +// +// // Get the singular PAT and user combination (failing immediately if it doesn't exist) +// let mut transaction = pool.begin().await?; +// let row = sqlx::query!( +// " +// SELECT id, name, scope, user_id, expires_at FROM pats +// WHERE id = $1 AND user_id = $2 +// ", +// pat_id.0, +// db_user_id.0 // included for safety +// ) +// .fetch_one(&**pool) +// .await?; +// +// let pat = PersonalAccessToken { +// id: to_base62(row.id as u64), +// access_token: None, +// user_id: UserId::from(db_user_id), +// name: info.name.unwrap_or(row.name), +// scope: row.scope, +// expires_at: info +// .expire_in_days +// .map(|d| Utc::now().naive_utc() + Duration::days(d)) +// .unwrap_or(row.expires_at), +// }; +// +// sqlx::query!( +// " +// UPDATE pats SET +// name = $1, +// expires_at = $2 +// WHERE id = $3 +// ", +// pat.name, +// pat.expires_at, +// parse_base62(&pat.id)? as i64 +// ) +// .execute(&mut *transaction) +// .await?; +// transaction.commit().await?; +// +// Ok(HttpResponse::Ok().json(pat)) +// } +// +// // DELETE /pat +// // Delete a personal access token for the given user. Minos/Kratos cookie must be attached for it to work. +// #[delete("pat/{id}")] +// pub async fn delete_pat( +// req: HttpRequest, +// id: web::Path, +// pool: Data, +// redis: web::Data, +// session_queue: web::Data, +// ) -> Result { +// let user: crate::models::users::User = +// get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; +// let pat_id = database::models::PatId(parse_base62(&id)? as i64); +// let db_user_id: database::models::UserId = database::models::UserId::from(user.id); +// +// // Get the singular PAT and user combination (failing immediately if it doesn't exist) +// // This is to prevent users from deleting other users' PATs +// let pat_id = sqlx::query!( +// " +// SELECT id FROM pats +// WHERE id = $1 AND user_id = $2 +// ", +// pat_id.0, +// db_user_id.0 +// ) +// .fetch_one(&**pool) +// .await? +// .id; +// +// let mut transaction = pool.begin().await?; +// sqlx::query!( +// " +// DELETE FROM pats +// WHERE id = $1 +// ", +// pat_id, +// ) +// .execute(&mut *transaction) +// .await?; +// transaction.commit().await?; +// +// Ok(HttpResponse::Ok().finish()) +// } diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 69f49c81e..3e3f6eb1b 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -10,6 +10,7 @@ use crate::models::projects::{ }; use crate::models::threads::ThreadType; use crate::models::users::UserId; +use crate::queue::session::SessionQueue; use crate::search::indexing::IndexingError; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; @@ -272,6 +273,7 @@ pub async fn project_create( client: Data, redis: Data, file_host: Data>, + session_queue: Data, ) -> Result { let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); @@ -284,6 +286,7 @@ pub async fn project_create( &mut uploaded_files, &client, &redis, + &session_queue, ) .await; @@ -331,6 +334,7 @@ Get logged in user - Add project data to indexing queue */ +#[allow(clippy::too_many_arguments)] async fn project_create_inner( req: HttpRequest, payload: &mut Multipart, @@ -339,12 +343,13 @@ async fn project_create_inner( uploaded_files: &mut Vec, pool: &PgPool, redis: &deadpool_redis::Pool, + session_queue: &SessionQueue, ) -> Result { // The base URL for files uploaded to backblaze let cdn_url = dotenvy::var("CDN_URL")?; // The currently logged in user - let current_user = get_user_from_headers(req.headers(), pool, redis).await?; + let current_user = get_user_from_headers(&req, pool, redis, session_queue).await?; let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 0e24df09c..3f74efc9d 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -11,6 +11,7 @@ use crate::models::projects::{ }; use crate::models::teams::Permissions; use crate::models::threads::MessageBody; +use crate::queue::session::SessionQueue; use crate::routes::ApiError; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::routes::read_from_payload; @@ -115,11 +116,12 @@ pub async fn projects_get( web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let ids = serde_json::from_str::>(&ids.ids)?; let projects_data = database::models::Project::get_many(&ids, &**pool, &redis).await?; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -134,12 +136,13 @@ pub async fn project_get( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let string = info.into_inner().0; let project_data = database::models::Project::get(&string, &**pool, &redis).await?; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -183,12 +186,13 @@ pub async fn dependency_list( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let string = info.into_inner().0; let result = database::models::Project::get(&string, &**pool, &redis).await?; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -353,8 +357,9 @@ pub async fn project_edit( config: web::Data, new_project: web::Json, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; new_project .validate() @@ -1176,8 +1181,9 @@ pub async fn projects_edit( pool: web::Data, bulk_edit_project: web::Json, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; bulk_edit_project .validate() @@ -1218,7 +1224,7 @@ pub async fn projects_edit( if !user.role.is_mod() { if let Some(member) = team_members .iter() - .find(|x| x.team_id == project.inner.team_id && x.user.id == user.id.into()) + .find(|x| x.team_id == project.inner.team_id && x.user_id == user.id.into()) { if !member.permissions.contains(Permissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication(format!( @@ -1505,9 +1511,10 @@ pub async fn project_schedule( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, scheduling_data: web::Json, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; if scheduling_data.time < Utc::now() { return Err(ApiError::InvalidInput( @@ -1575,6 +1582,7 @@ pub struct Extension { } #[patch("{id}/icon")] +#[allow(clippy::too_many_arguments)] pub async fn project_icon_edit( web::Query(ext): web::Query, req: HttpRequest, @@ -1583,10 +1591,11 @@ pub async fn project_icon_edit( redis: web::Data, file_host: web::Data>, mut payload: web::Payload, + session_queue: web::Data, ) -> Result { if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string = info.into_inner().0; let project_item = database::models::Project::get(&string, &**pool, &redis) @@ -1678,8 +1687,9 @@ pub async fn delete_project_icon( pool: web::Data, redis: web::Data, file_host: web::Data>, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string = info.into_inner().0; let project_item = database::models::Project::get(&string, &**pool, &redis) @@ -1763,13 +1773,14 @@ pub async fn add_gallery_item( redis: web::Data, file_host: web::Data>, mut payload: web::Payload, + session_queue: web::Data, ) -> Result { if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { item.validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string = info.into_inner().0; let project_item = database::models::Project::get(&string, &**pool, &redis) @@ -1904,8 +1915,9 @@ pub async fn edit_gallery_item( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string = info.into_inner().0; item.validate() @@ -2049,8 +2061,9 @@ pub async fn delete_gallery_item( pool: web::Data, redis: web::Data, file_host: web::Data>, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string = info.into_inner().0; let project_item = database::models::Project::get(&string, &**pool, &redis) @@ -2135,8 +2148,9 @@ pub async fn project_delete( pool: web::Data, redis: web::Data, config: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string = info.into_inner().0; let project = database::models::Project::get(&string, &**pool, &redis) @@ -2189,8 +2203,9 @@ pub async fn project_follow( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string = info.into_inner().0; let result = database::models::Project::get(&string, &**pool, &redis) @@ -2259,8 +2274,9 @@ pub async fn project_unfollow( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string = info.into_inner().0; let result = database::models::Project::get(&string, &**pool, &redis) diff --git a/src/routes/v2/reports.rs b/src/routes/v2/reports.rs index 11b7d4b3b..c93b528d1 100644 --- a/src/routes/v2/reports.rs +++ b/src/routes/v2/reports.rs @@ -3,6 +3,7 @@ use crate::database::models::thread_item::{ThreadBuilder, ThreadMessageBuilder}; use crate::models::ids::{base62_impl::parse_base62, ProjectId, UserId, VersionId}; use crate::models::reports::{ItemType, Report}; use crate::models::threads::{MessageBody, ThreadType}; +use crate::queue::session::SessionQueue; use crate::routes::ApiError; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::Utc; @@ -34,10 +35,11 @@ pub async fn report_create( pool: web::Data, mut body: web::Payload, redis: web::Data, + session_queue: web::Data, ) -> Result { let mut transaction = pool.begin().await?; - let current_user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let mut bytes = web::BytesMut::new(); while let Some(item) = body.next().await { @@ -180,8 +182,9 @@ pub async fn reports( pool: web::Data, redis: web::Data, count: web::Query, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; use futures::stream::TryStreamExt; @@ -245,6 +248,7 @@ pub async fn reports_get( web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let report_ids: Vec = serde_json::from_str::>(&ids.ids)? @@ -255,7 +259,7 @@ pub async fn reports_get( let reports_data = crate::database::models::report_item::Report::get_many(&report_ids, &**pool).await?; - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let all_reports = reports_data .into_iter() @@ -272,8 +276,9 @@ pub async fn report_get( pool: web::Data, redis: web::Data, info: web::Path<(crate::models::reports::ReportId,)>, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id = info.into_inner().0.into(); let report = crate::database::models::report_item::Report::get(id, &**pool).await?; @@ -303,9 +308,10 @@ pub async fn report_edit( pool: web::Data, redis: web::Data, info: web::Path<(crate::models::reports::ReportId,)>, + session_queue: web::Data, edit_report: web::Json, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id = info.into_inner().0.into(); let report = crate::database::models::report_item::Report::get(id, &**pool).await?; @@ -379,8 +385,9 @@ pub async fn report_delete( pool: web::Data, info: web::Path<(crate::models::reports::ReportId,)>, redis: web::Data, + session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers(req.headers(), &**pool, &redis).await?; + check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; let mut transaction = pool.begin().await?; let result = crate::database::models::report_item::Report::remove_full( diff --git a/src/routes/v2/teams.rs b/src/routes/v2/teams.rs index 732679061..64802a59c 100644 --- a/src/routes/v2/teams.rs +++ b/src/routes/v2/teams.rs @@ -5,6 +5,7 @@ use crate::models::ids::ProjectId; use crate::models::notifications::NotificationBody; use crate::models::teams::{Permissions, TeamId}; use crate::models::users::UserId; +use crate::queue::session::SessionQueue; use crate::routes::ApiError; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use rust_decimal::Decimal; @@ -31,47 +32,53 @@ pub async fn team_members_get_project( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let string = info.into_inner().0; let project_data = crate::database::models::Project::get(&string, &**pool, &redis).await?; if let Some(project) = project_data { - let current_user = get_user_from_headers(req.headers(), &**pool, &redis) + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); - let members_data = - TeamMember::get_from_team_full(project.inner.team_id, &**pool, &redis).await?; - if !is_authorized(&project.inner, ¤t_user, &pool).await? { return Ok(HttpResponse::NotFound().body("")); } - if let Some(user) = ¤t_user { - let team_member = members_data - .iter() - .find(|x| x.user.id == user.id.into() && x.accepted); + let members_data = + TeamMember::get_from_team_full(project.inner.team_id, &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; - if team_member.is_some() { - let team_members: Vec<_> = members_data - .into_iter() - .map(|data| crate::models::teams::TeamMember::from(data, false)) - .collect(); + let user_id = current_user.as_ref().map(|x| x.id.into()); - return Ok(HttpResponse::Ok().json(team_members)); - } - } + let logged_in = current_user + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); - let user_id = current_user.map(|x| x.id.into()); let team_members: Vec<_> = members_data .into_iter() .filter(|x| { - x.accepted + logged_in + || x.accepted || user_id - .map(|y: crate::database::models::UserId| y == x.user.id) + .map(|y: crate::database::models::UserId| y == x.user_id) .unwrap_or(false) }) - .map(|data| crate::models::teams::TeamMember::from(data, true)) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) .collect(); Ok(HttpResponse::Ok().json(team_members)) @@ -86,39 +93,45 @@ pub async fn team_members_get( info: web::Path<(TeamId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let id = info.into_inner().0; let members_data = TeamMember::get_from_team_full(id.into(), &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; - let current_user = get_user_from_headers(req.headers(), &**pool, &redis) + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); - if let Some(user) = ¤t_user { - let team_member = members_data - .iter() - .find(|x| x.user.id == user.id.into() && x.accepted); + let logged_in = current_user + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); - if team_member.is_some() { - let team_members: Vec<_> = members_data - .into_iter() - .map(|data| crate::models::teams::TeamMember::from(data, false)) - .collect(); - - return Ok(HttpResponse::Ok().json(team_members)); - } - } - - let user_id = current_user.map(|x| x.id.into()); let team_members: Vec<_> = members_data .into_iter() .filter(|x| { - x.accepted + logged_in + || x.accepted || user_id - .map(|y: crate::database::models::UserId| y == x.user.id) + .map(|y: crate::database::models::UserId| y == x.user_id) .unwrap_or(false) }) - .map(|data| crate::models::teams::TeamMember::from(data, true)) + .flat_map(|data| { + users + .iter() + .find(|x| x.id == data.user_id) + .map(|user| crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)) + }) .collect(); Ok(HttpResponse::Ok().json(team_members)) @@ -135,6 +148,7 @@ pub async fn teams_get( web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { use itertools::Itertools; @@ -144,8 +158,14 @@ pub async fn teams_get( .collect::>(); let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; - let current_user = get_user_from_headers(req.headers(), &**pool, &redis) + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -156,28 +176,23 @@ pub async fn teams_get( for (_, member_data) in &teams_groups { let members = member_data.collect::>(); - let team_member = if let Some(user) = ¤t_user { - members - .iter() - .find(|x| x.user.id == user.id.into() && x.accepted) - } else { - None - }; - - if team_member.is_some() { - let team_members = members - .into_iter() - .map(|data| crate::models::teams::TeamMember::from(data, false)); - - teams.push(team_members.collect()); - - continue; - } + let logged_in = current_user + .as_ref() + .and_then(|user| { + members + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); let team_members = members .into_iter() - .filter(|x| x.accepted) - .map(|data| crate::models::teams::TeamMember::from(data, true)); + .filter(|x| logged_in || x.accepted) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }); teams.push(team_members.collect()); } @@ -191,9 +206,10 @@ pub async fn join_team( info: web::Path<(TeamId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let team_id = info.into_inner().0.into(); - let current_user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let member = TeamMember::get_from_user_id_pending(team_id, current_user.id.into(), &**pool).await?; @@ -259,12 +275,13 @@ pub async fn add_team_member( pool: web::Data, new_member: web::Json, redis: web::Data, + session_queue: web::Data, ) -> Result { let team_id = info.into_inner().0.into(); let mut transaction = pool.begin().await?; - let current_user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let member = TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool) .await? .ok_or_else(|| { @@ -373,12 +390,13 @@ pub async fn edit_team_member( pool: web::Data, edit_member: web::Json, redis: web::Data, + session_queue: web::Data, ) -> Result { let ids = info.into_inner(); let id = ids.0.into(); let user_id = ids.1.into(); - let current_user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool) .await? .ok_or_else(|| { @@ -463,10 +481,11 @@ pub async fn transfer_ownership( pool: web::Data, new_owner: web::Json, redis: web::Data, + session_queue: web::Data, ) -> Result { let id = info.into_inner().0; - let current_user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; if !current_user.role.is_admin() { let member = TeamMember::get_from_user_id(id.into(), current_user.id.into(), &**pool) @@ -535,12 +554,13 @@ pub async fn remove_team_member( info: web::Path<(TeamId, UserId)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let ids = info.into_inner(); let id = ids.0.into(); let user_id = ids.1.into(); - let current_user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool) .await? .ok_or_else(|| { diff --git a/src/routes/v2/threads.rs b/src/routes/v2/threads.rs index d120ba17d..3a64f3c0c 100644 --- a/src/routes/v2/threads.rs +++ b/src/routes/v2/threads.rs @@ -7,6 +7,7 @@ use crate::models::notifications::NotificationBody; use crate::models::projects::ProjectStatus; use crate::models::threads::{MessageBody, Thread, ThreadId, ThreadType}; use crate::models::users::User; +use crate::queue::session::SessionQueue; use crate::routes::ApiError; use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; use futures::TryStreamExt; @@ -211,12 +212,13 @@ pub async fn thread_get( info: web::Path<(ThreadId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let string = info.into_inner().0.into(); let thread_data = database::models::Thread::get(string, &**pool).await?; - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; if let Some(mut data) = thread_data { if is_authorized_thread(&data, &user, &pool).await? { @@ -253,8 +255,9 @@ pub async fn threads_get( web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let thread_ids: Vec = serde_json::from_str::>(&ids.ids)? @@ -281,8 +284,9 @@ pub async fn thread_send_message( pool: web::Data, new_message: web::Json, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let string: database::models::ThreadId = info.into_inner().0.into(); @@ -370,7 +374,7 @@ pub async fn thread_send_message( }, } .insert_many( - members.into_iter().map(|x| x.user.id).collect(), + members.into_iter().map(|x| x.user_id).collect(), &mut transaction, ) .await?; @@ -442,8 +446,9 @@ pub async fn moderation_inbox( req: HttpRequest, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = check_is_moderator_from_headers(req.headers(), &**pool, &redis).await?; + let user = check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; let ids = sqlx::query!( " @@ -469,8 +474,9 @@ pub async fn thread_read( info: web::Path<(ThreadId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers(req.headers(), &**pool, &redis).await?; + check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; let id = info.into_inner().0; let mut transaction = pool.begin().await?; @@ -497,8 +503,9 @@ pub async fn message_delete( info: web::Path<(ThreadMessageId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let result = database::models::ThreadMessage::get(info.into_inner().0.into(), &**pool).await?; diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index 990318f70..b3475131e 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -5,12 +5,17 @@ use crate::models::notifications::Notification; use crate::models::projects::Project; use crate::models::users::{Badges, RecipientType, RecipientWallet, Role, UserId}; use crate::queue::payouts::{PayoutAmount, PayoutItem, PayoutsQueue}; +use crate::queue::session::SessionQueue; use crate::routes::ApiError; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use chrono::{DateTime, Utc}; use lazy_static::lazy_static; +use rand_chacha::rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; use regex::Regex; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -44,8 +49,12 @@ pub async fn user_auth_get( req: HttpRequest, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - Ok(HttpResponse::Ok().json(get_user_from_headers(req.headers(), &**pool, &redis).await?)) + Ok( + HttpResponse::Ok() + .json(get_user_from_headers(&req, &**pool, &redis, &session_queue).await?), + ) } #[derive(Serialize)] @@ -59,8 +68,9 @@ pub async fn user_data_get( req: HttpRequest, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let data = sqlx::query!( " @@ -128,8 +138,9 @@ pub async fn projects_list( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis) + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -189,6 +200,7 @@ pub struct EditUser { )] #[validate] pub payout_data: Option>, + pub password: Option<(Option, Option)>, } #[derive(Serialize, Deserialize, Validate)] @@ -206,8 +218,9 @@ pub async fn user_edit( new_user: web::Json, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; new_user .validate() @@ -387,6 +400,78 @@ pub async fn user_edit( } } + if let Some((old_password, new_password)) = &new_user.password { + if let Some(pass) = actual_user.password { + let old_password = old_password.as_ref().ok_or_else(|| { + ApiError::CustomAuthentication( + "You must specify the old password to change your password!" + .to_string(), + ) + })?; + + let hasher = Argon2::default(); + hasher.verify_password(old_password.as_bytes(), &PasswordHash::new(&pass)?)?; + } + + let update_password = if let Some(new_password) = new_password { + let score = zxcvbn::zxcvbn( + new_password, + &[ + &actual_user.username, + &actual_user.email.unwrap_or_default(), + &actual_user.name.unwrap_or_default(), + ], + )?; + + if score.score() < 3 { + return Err(ApiError::InvalidInput( + if let Some(feedback) = + score.feedback().clone().and_then(|x| x.warning()) + { + format!("Password too weak: {}", feedback) + } else { + "Specified password is too weak! Please improve its strength." + .to_string() + }, + )); + } + + let hasher = Argon2::default(); + let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy()); + let password_hash = hasher + .hash_password(new_password.as_bytes(), &salt)? + .to_string(); + + Some(password_hash) + } else { + if !(actual_user.github_id.is_some() + || actual_user.gitlab_id.is_some() + || actual_user.microsoft_id.is_some() + || actual_user.google_id.is_some() + || actual_user.steam_id.is_some() + || actual_user.discord_id.is_some()) + { + return Err(ApiError::InvalidInput( + "You must have another authentication method added to remove password authentication!".to_string(), + )); + } + + None + }; + + sqlx::query!( + " + UPDATE users + SET password = $1 + WHERE (id = $2) + ", + update_password, + 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("")) @@ -406,6 +491,7 @@ pub struct Extension { } #[patch("{id}/icon")] +#[allow(clippy::too_many_arguments)] pub async fn user_icon_edit( web::Query(ext): web::Query, req: HttpRequest, @@ -414,10 +500,11 @@ pub async fn user_icon_edit( redis: web::Data, file_host: web::Data>, mut payload: web::Payload, + session_queue: web::Data, ) -> Result { if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; if let Some(actual_user) = id_option { @@ -492,8 +579,9 @@ pub async fn user_delete( pool: web::Data, removal_type: web::Query, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; if let Some(id) = id_option.map(|x| x.id) { @@ -531,8 +619,9 @@ pub async fn user_follows( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; if let Some(id) = id_option.map(|x| x.id) { @@ -578,8 +667,9 @@ pub async fn user_notifications( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; if let Some(id) = id_option.map(|x| x.id) { @@ -617,8 +707,9 @@ pub async fn user_payouts( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; if let Some(id) = id_option.map(|x| x.id) { @@ -691,12 +782,13 @@ pub async fn user_payouts_request( info: web::Path<(String,)>, pool: web::Data, data: web::Json, - payouts_queue: web::Data>>, + payouts_queue: web::Data>, redis: web::Data, + session_queue: web::Data, ) -> Result { let mut payouts_queue = payouts_queue.lock().await; - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; if let Some(id) = id_option.map(|x| x.id) { diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 40977b914..05ed58337 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -13,6 +13,7 @@ use crate::models::projects::{ VersionId, VersionStatus, VersionType, }; use crate::models::teams::Permissions; +use crate::queue::session::SessionQueue; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use crate::validate::{validate_file, ValidationResult}; @@ -84,6 +85,7 @@ pub async fn version_create( client: Data, redis: Data, file_host: Data>, + session_queue: Data, ) -> Result { let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); @@ -96,6 +98,7 @@ pub async fn version_create( &***file_host, &mut uploaded_files, &client, + &session_queue, ) .await; @@ -115,6 +118,7 @@ pub async fn version_create( result } +#[allow(clippy::too_many_arguments)] async fn version_create_inner( req: HttpRequest, payload: &mut Multipart, @@ -123,6 +127,7 @@ async fn version_create_inner( file_host: &dyn FileHost, uploaded_files: &mut Vec, pool: &PgPool, + session_queue: &SessionQueue, ) -> Result { let cdn_url = dotenvy::var("CDN_URL")?; @@ -132,7 +137,7 @@ async fn version_create_inner( let all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?; let all_loaders = models::categories::Loader::list(&mut *transaction).await?; - let user = get_user_from_headers(req.headers(), pool, redis).await?; + let user = get_user_from_headers(&req, pool, redis, session_queue).await?; let mut error = None; while let Some(item) = payload.next().await { @@ -434,8 +439,9 @@ pub async fn upload_file_to_version( url_data: web::Path<(VersionId,)>, mut payload: Multipart, client: Data, - redis: web::Data, + redis: Data, file_host: Data>, + session_queue: web::Data, ) -> Result { let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); @@ -451,6 +457,7 @@ pub async fn upload_file_to_version( &***file_host, &mut uploaded_files, version_id, + &session_queue, ) .await; @@ -480,13 +487,14 @@ async fn upload_file_to_version_inner( file_host: &dyn FileHost, uploaded_files: &mut Vec, version_id: models::VersionId, + session_queue: &SessionQueue, ) -> Result { let cdn_url = dotenvy::var("CDN_URL")?; let mut initial_file_data: Option = None; let mut file_builders: Vec = Vec::new(); - let user = get_user_from_headers(req.headers(), &**client, &redis).await?; + let user = get_user_from_headers(&req, &**client, &redis, session_queue).await?; let result = models::Version::get(version_id, &**client, &redis).await?; diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index dabcdfaad..bf36ec2f7 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -6,6 +6,7 @@ use crate::auth::{ use crate::models::ids::VersionId; use crate::models::projects::VersionType; use crate::models::teams::Permissions; +use crate::queue::session::SessionQueue; use crate::{database, models}; use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; use itertools::Itertools; @@ -48,8 +49,9 @@ pub async fn get_version_from_hash( pool: web::Data, redis: web::Data, hash_query: web::Query, + session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -93,8 +95,9 @@ pub async fn download_version( pool: web::Data, redis: web::Data, hash_query: web::Query, + session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -135,8 +138,9 @@ pub async fn delete_file( pool: web::Data, redis: web::Data, hash_query: web::Query, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let hash = info.into_inner().0.to_lowercase(); @@ -230,8 +234,9 @@ pub async fn get_update_from_hash( redis: web::Data, hash_query: web::Query, update_data: web::Json, + session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); let hash = info.into_inner().0.to_lowercase(); @@ -299,8 +304,9 @@ pub async fn get_versions_from_hashes( pool: web::Data, redis: web::Data, file_data: web::Json, + session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -339,8 +345,9 @@ pub async fn get_projects_from_hashes( pool: web::Data, redis: web::Data, file_data: web::Json, + session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -389,8 +396,9 @@ pub async fn update_files( pool: web::Data, redis: web::Data, update_data: web::Json, + session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 7758ec9dd..fed35ec61 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -6,6 +6,7 @@ use crate::database; use crate::models; use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; use crate::models::teams::Permissions; +use crate::queue::session::SessionQueue; use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; @@ -44,12 +45,13 @@ pub async fn version_list( web::Query(filters): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let string = info.into_inner().0; let result = database::models::Project::get(&string, &**pool, &redis).await?; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -152,12 +154,13 @@ pub async fn version_project_get( info: web::Path<(String, String)>, pool: web::Data, redis: web::Data, + 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 user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -181,6 +184,7 @@ pub async fn versions_get( web::Query(ids): web::Query, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let version_ids = serde_json::from_str::>(&ids.ids)? .into_iter() @@ -188,7 +192,7 @@ pub async fn versions_get( .collect::>(); let versions_data = database::models::Version::get_many(&version_ids, &**pool, &redis).await?; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -203,11 +207,12 @@ pub async fn version_get( info: web::Path<(models::ids::VersionId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { let id = info.into_inner().0; let version_data = database::models::Version::get(id.into(), &**pool, &redis).await?; - let user_option = get_user_from_headers(req.headers(), &**pool, &redis) + let user_option = get_user_from_headers(&req, &**pool, &redis, &session_queue) .await .ok(); @@ -263,8 +268,9 @@ pub async fn version_edit( pool: web::Data, redis: web::Data, new_version: web::Json, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; new_version .validate() @@ -639,8 +645,9 @@ pub async fn version_schedule( pool: web::Data, redis: web::Data, scheduling_data: web::Json, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; if scheduling_data.time < Utc::now() { return Err(ApiError::InvalidInput( @@ -704,8 +711,9 @@ pub async fn version_delete( info: web::Path<(models::ids::VersionId,)>, pool: web::Data, redis: web::Data, + session_queue: web::Data, ) -> Result { - let user = get_user_from_headers(req.headers(), &**pool, &redis).await?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?; let id = info.into_inner().0; let version = database::models::Version::get(id.into(), &**pool, &redis) diff --git a/src/util/captcha.rs b/src/util/captcha.rs new file mode 100644 index 000000000..276eac31b --- /dev/null +++ b/src/util/captcha.rs @@ -0,0 +1,41 @@ +use crate::routes::ApiError; +use crate::util::env::parse_var; +use actix_web::HttpRequest; +use serde::Deserialize; +use serde_json::json; + +pub async fn check_turnstile_captcha(req: &HttpRequest, challenge: &str) -> Result { + let conn_info = req.connection_info().clone(); + let ip_addr = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) { + if let Some(header) = req.headers().get("CF-Connecting-IP") { + header.to_str().ok() + } else { + conn_info.peer_addr() + } + } else { + conn_info.peer_addr() + }; + + let client = reqwest::Client::new(); + + #[derive(Deserialize)] + struct Response { + success: bool, + } + + let val: Response = client + .post("https://challenges.cloudflare.com/turnstile/v0/siteverify") + .json(&json!({ + "secret": dotenvy::var("TURNSTILE_SECRET")?, + "response": challenge, + "remoteip": ip_addr, + })) + .send() + .await + .map_err(|_| ApiError::Turnstile)? + .json() + .await + .map_err(|_| ApiError::Turnstile)?; + + Ok(val.success) +} diff --git a/src/util/mod.rs b/src/util/mod.rs index e0151ed11..d34e5a894 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,4 @@ +pub mod captcha; pub mod env; pub mod ext; pub mod guards; diff --git a/src/util/webhook.rs b/src/util/webhook.rs index 36ca6a1aa..89b8d3d63 100644 --- a/src/util/webhook.rs +++ b/src/util/webhook.rs @@ -260,9 +260,7 @@ pub async fn send_discord_webhook( }) .send() .await - .map_err(|_| { - ApiError::DiscordError("Error while sending projects webhook".to_string()) - })?; + .map_err(|_| ApiError::Discord("Error while sending projects webhook".to_string()))?; } Ok(())