diff --git a/.env b/.env index 8d033da92..e464a5d01 100644 --- a/.env +++ b/.env @@ -21,6 +21,10 @@ MEILISEARCH_KEY=modrinth BIND_ADDR=127.0.0.1:8000 MOCK_FILE_PATH=/tmp/modrinth +MINOS_URL=http://127.0.0.1:4000 +KRATOS_URL=http://127.0.0.1:4433 +ORY_AUTH_BEARER=none + STORAGE_BACKEND=local BACKBLAZE_KEY_ID=none @@ -45,7 +49,7 @@ RATE_LIMIT_IGNORE_IPS='["127.0.0.1"]' WHITELISTED_MODPACK_DOMAINS='["cdn.modrinth.com", "edge.forgecdn.net", "github.com", "raw.githubusercontent.com"]' -ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com"]' +ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com", "127.0.0.1"]' ARIADNE_ADMIN_KEY=feedbeef ARIADNE_URL=https://staging-ariadne.modrinth.com/v1/ diff --git a/migrations/20230502141522_minos-support.sql b/migrations/20230502141522_minos-support.sql new file mode 100644 index 000000000..132de5743 --- /dev/null +++ b/migrations/20230502141522_minos-support.sql @@ -0,0 +1,16 @@ +-- No longer have banned users in Labrinth +DROP TABLE banned_users; + +-- Initialize kratos_id +ALTER TABLE users ADD COLUMN kratos_id varchar(40) UNIQUE; + +-- Add pats table +CREATE TABLE pats ( + id BIGINT PRIMARY KEY, + name VARCHAR(255), + user_id BIGINT NOT NULL REFERENCES users(id), + access_token VARCHAR(64) NOT NULL, + scope BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); \ No newline at end of file diff --git a/sqlx-data.json b/sqlx-data.json index 5482ee16d..ee3f2634f 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -66,25 +66,6 @@ }, "query": "\n UPDATE versions\n SET status = $1, date_published = $2\n WHERE (id = $3)\n " }, - "03209c5bda2d704e688439919a7b3903db6ad7caebf7ddafb3ea52d312d47bfb": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Timestamptz" - ] - } - }, - "query": "\n INSERT INTO users (\n id, github_id, username, name, email,\n avatar_url, bio, created\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8\n )\n " - }, "03284fe5b045e2cf93f160863c4d121439382b348b728fffb5ac588dee980731": { "describe": { "columns": [ @@ -331,6 +312,20 @@ }, "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": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Timestamp", + "Int8" + ] + } + }, + "query": "\n UPDATE pats SET\n name = $1,\n expires_at = $2\n WHERE id = $3\n " + }, "0f0244e77f60e69b3ab1320265749656e25da0b021b3df9013a2da470dbc8d46": { "describe": { "columns": [], @@ -870,6 +865,110 @@ }, "query": "\n INSERT INTO reports (\n id, report_type_id, mod_id, version_id, user_id,\n body, reporter, thread_id\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8\n )\n " }, + "14ecb5fda3352c8bba9fc0b4570639f222f6fa83bbc9ecc618dca61761118f0e": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "kratos_id", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "github_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "avatar_url", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 6, + "type_info": "Varchar" + }, + { + "name": "bio", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "created", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "role", + "ordinal": 9, + "type_info": "Varchar" + }, + { + "name": "badges", + "ordinal": 10, + "type_info": "Int8" + }, + { + "name": "balance", + "ordinal": 11, + "type_info": "Numeric" + }, + { + "name": "payout_wallet", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "payout_wallet_type", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "payout_address", + "ordinal": 14, + "type_info": "Varchar" + } + ], + "nullable": [ + false, + true, + true, + true, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + true + ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "query": "\n SELECT u.id, u.kratos_id, u.name, u.email, u.github_id,\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,\n u.payout_address\n FROM users u\n WHERE u.id = ANY($1)\n " + }, "15b8ea323c2f6d03c2e385d9c46d7f13460764f2f106fd638226c42ae0217f75": { "describe": { "columns": [], @@ -1584,6 +1683,23 @@ }, "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": [ @@ -1814,18 +1930,6 @@ }, "query": "\n SELECT thread_id FROM reports\n WHERE id = $1\n " }, - "28d5825964b0fddc43bd7d6851daf91845b79c9e88c82d5c7d97ae02502d0b4f": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "INSERT INTO banned_users (github_id) VALUES ($1);" - }, "294f264382ad55475b51776cd5d306c4867e8e6966ab79921bba69dc023f8337": { "describe": { "columns": [], @@ -1931,18 +2035,6 @@ }, "query": "\n SELECT COUNT(v.id)\n FROM versions v\n INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)\n WHERE v.status = ANY($2)\n " }, - "2f7c011654d15c85dbb614ac01ed5613a6872ea8c172ab38fdaa0eb38a7d6e4f": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "DELETE FROM banned_users WHERE github_id = $1;" - }, "320d73cd900a6e00f0e74b7a8c34a7658d16034b01a35558cb42fa9c16185eb5": { "describe": { "columns": [ @@ -2052,6 +2144,18 @@ }, "query": "SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)" }, + "36a56feb27d6f07b7c3e7a1aa3a9e6358b5c1b08e7961343a021e2b1790877f5": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM states\n WHERE id = $1\n " + }, "371048e45dd74c855b84cdb8a6a565ccbef5ad166ec9511ab20621c336446da6": { "describe": { "columns": [], @@ -2373,6 +2477,116 @@ }, "query": "\n SELECT gv.id id, gv.version version_, gv.type type_, gv.created created, gv.major major FROM game_versions gv\n WHERE major = $1 AND type = $2\n ORDER BY created DESC\n " }, + "448e0dc80b5722160a52bdcae7e256524a1b6ebd7cd918566e90383693b519b9": { + "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": "kratos_id", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "email", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "github_id", + "ordinal": 5, + "type_info": "Int8" + }, + { + "name": "avatar_url", + "ordinal": 6, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "bio", + "ordinal": 8, + "type_info": "Varchar" + }, + { + "name": "created", + "ordinal": 9, + "type_info": "Timestamptz" + }, + { + "name": "role", + "ordinal": 10, + "type_info": "Varchar" + }, + { + "name": "badges", + "ordinal": 11, + "type_info": "Int8" + }, + { + "name": "balance", + "ordinal": 12, + "type_info": "Numeric" + }, + { + "name": "payout_wallet", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "payout_wallet_type", + "ordinal": 14, + "type_info": "Varchar" + }, + { + "name": "payout_address", + "ordinal": 15, + "type_info": "Varchar" + } + ], + "nullable": [ + false, + false, + true, + true, + true, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + true + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT pats.expires_at,\n u.id, u.name, u.kratos_id, u.email, u.github_id,\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,\n u.payout_address\n FROM pats LEFT OUTER JOIN users u ON pats.user_id = u.id\n WHERE access_token = $1\n " + }, "4567790f0dc98ff20b596a33161d1f6ac8af73da67fe8c54192724626c6bf670": { "describe": { "columns": [], @@ -2677,104 +2891,6 @@ }, "query": "\n DELETE FROM game_versions_versions WHERE joining_version_id = $1\n " }, - "50fc72532d4b61d117b6245b5e315f3d88e9fe3a67f4522d281270911177f3ed": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "github_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" - } - ], - "nullable": [ - false, - true, - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - true, - true - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT u.id, u.github_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,\n u.payout_address\n FROM users u\n WHERE LOWER(u.username) = LOWER($1)\n " - }, "515a3629aeef7d0789fe5e57a28d77aaa35a27cb7b35df70c959f95ccbbc25f3": { "describe": { "columns": [ @@ -3124,6 +3240,27 @@ }, "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": [], @@ -3183,26 +3320,6 @@ }, "query": "\n SELECT id FROM report_types\n WHERE name = $1\n " }, - "69bb839ea7fd5687538656e1907599d75e2c4948a54d58446bec8a90170ee618": { - "describe": { - "columns": [ - { - "name": "user", - "ordinal": 0, - "type_info": "Name" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "SELECT user FROM banned_users WHERE github_id = $1" - }, "6a7b7704c2a0c52a70f5d881a1e6d3e8e77ddaa83ecc5688cd86bf327775fb76": { "describe": { "columns": [ @@ -3236,6 +3353,152 @@ }, "query": "\n DELETE FROM mods\n WHERE id = $1\n " }, + "6b5be5b92fe666049c5ae0847d315101f1e625a031f4a73c2b0418e246c319fe": { + "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": "email", + "ordinal": 9, + "type_info": "Varchar" + }, + { + "name": "kratos_id", + "ordinal": 10, + "type_info": "Varchar" + }, + { + "name": "github_id", + "ordinal": 11, + "type_info": "Int8" + }, + { + "name": "avatar_url", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "bio", + "ordinal": 14, + "type_info": "Varchar" + }, + { + "name": "created", + "ordinal": 15, + "type_info": "Timestamptz" + }, + { + "name": "user_role", + "ordinal": 16, + "type_info": "Varchar" + }, + { + "name": "badges", + "ordinal": 17, + "type_info": "Int8" + }, + { + "name": "balance", + "ordinal": 18, + "type_info": "Numeric" + }, + { + "name": "payout_wallet", + "ordinal": 19, + "type_info": "Varchar" + }, + { + "name": "payout_wallet_type", + "ordinal": 20, + "type_info": "Varchar" + }, + { + "name": "payout_address", + "ordinal": 21, + "type_info": "Varchar" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + true + ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "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, u.email email, u.kratos_id kratos_id, u.github_id github_id,\n u.avatar_url avatar_url, u.username username, u.bio bio,\n u.created created, u.role user_role, u.badges badges, u.balance balance,\n u.payout_wallet payout_wallet, u.payout_wallet_type payout_wallet_type,\n u.payout_address payout_address\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 " + }, "6c4a42c263ae2787744aa6903e3cd85e90beaa5bea7ba78b45dbf55ce007753d": { "describe": { "columns": [ @@ -3805,6 +4068,19 @@ }, "query": "\n INSERT INTO mods_categories (joining_mod_id, joining_category_id, is_additional)\n VALUES ($1, $2, FALSE)\n " }, + "801a4cec0f8621ea8f365bc29213cd94aa529dc001aac51feefafe955469b3ce": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET kratos_id = $1\n WHERE (id = $2)\n " + }, "83d428e1c07d16e356ef26bdf1d707940b1683b5f631ded1f6674a081453d67b": { "describe": { "columns": [], @@ -3942,98 +4218,6 @@ }, "query": "\n UPDATE mods\n SET loaders = (\n SELECT COALESCE(ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null), array[]::varchar[])\n FROM versions v\n INNER JOIN loaders_versions lv ON lv.version_id = v.id\n INNER JOIN loaders l on lv.loader_id = l.id\n WHERE v.mod_id = mods.id AND v.status != ANY($2)\n )\n WHERE id = $1\n " }, - "886cc346f5ecc958018f7cab7dc3db9f8766fcdc7b16d686504ddcb6c5dde0b0": { - "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" - } - ], - "nullable": [ - false, - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - true, - true - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n SELECT 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,\n u.payout_address\n FROM users u\n WHERE u.github_id = $1\n " - }, "8a7b2bc070e5e8308e2853ff125bc98f40b22c1d0deeb013dd90ce5768bd0ce8": { "describe": { "columns": [], @@ -4254,6 +4438,26 @@ }, "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 " + }, "9dc32a9ef59f57fbad862520b6d3a4795a95d7d0db17e05eb8aedc3a2fe600dc": { "describe": { "columns": [ @@ -4394,18 +4598,6 @@ }, "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.accepted, tm.payouts_split, tm.ordering FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE\n WHERE m.id = $1\n " }, - "a39ce28b656032f862b205cffa393a76b989f4803654a615477a94fda5f57354": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM states\n WHERE id = $1\n " - }, "a3e27b758ca441fa82f6bcd42915b92fb23a7db19a7eb27db7ed92eeba4b566e": { "describe": { "columns": [ @@ -4474,104 +4666,6 @@ }, "query": "\n DELETE FROM states\n WHERE expires < CURRENT_DATE\n " }, - "a91e7409e72211acf36cdcc4ee3395ef350acbf7be401e190dfbabdf60ebe155": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "github_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" - } - ], - "nullable": [ - false, - true, - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - true, - true - ], - "parameters": { - "Left": [ - "Int8Array" - ] - } - }, - "query": "\n SELECT u.id, u.github_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,\n u.payout_address\n FROM users u\n WHERE u.id = ANY($1)\n " - }, "a962f21969bba402258fca169c45f3d71bc1b71f754cdcc1f5c968e4948653b2": { "describe": { "columns": [ @@ -4703,6 +4797,26 @@ }, "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": [], @@ -4787,6 +4901,110 @@ }, "query": "\n UPDATE users\n SET balance = balance - $1\n WHERE id = $2\n " }, + "b4396b519f31b9ccf4a3cde67fea204365bc2ea3cbe81705af25ae32b06ef579": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "kratos_id", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "github_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "avatar_url", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 6, + "type_info": "Varchar" + }, + { + "name": "bio", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "created", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "role", + "ordinal": 9, + "type_info": "Varchar" + }, + { + "name": "badges", + "ordinal": 10, + "type_info": "Int8" + }, + { + "name": "balance", + "ordinal": 11, + "type_info": "Numeric" + }, + { + "name": "payout_wallet", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "payout_wallet_type", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "payout_address", + "ordinal": 14, + "type_info": "Varchar" + } + ], + "nullable": [ + false, + true, + true, + true, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + true + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT u.id, u.name, u.kratos_id, u.email, u.github_id,\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,\n u.payout_address\n FROM users u\n WHERE u.kratos_id = $1\n " + }, "b69a6f42965b3e7103fcbf46e39528466926789ff31e9ed2591bb175527ec169": { "describe": { "columns": [], @@ -5326,6 +5544,50 @@ }, "query": "\n DELETE FROM reports\n WHERE mod_id = $1\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": [ @@ -5464,6 +5726,110 @@ }, "query": "\n SELECT f.id id FROM hashes h\n INNER JOIN files f ON h.file_id = f.id\n WHERE h.algorithm = $2 AND h.hash = $1\n " }, + "d1b58b57d019cf95ce2a0f4b92b34b3bdd7d27aa50dba72242458369eaa7e750": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "kratos_id", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "github_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "avatar_url", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 6, + "type_info": "Varchar" + }, + { + "name": "bio", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "created", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "role", + "ordinal": 9, + "type_info": "Varchar" + }, + { + "name": "badges", + "ordinal": 10, + "type_info": "Int8" + }, + { + "name": "balance", + "ordinal": 11, + "type_info": "Numeric" + }, + { + "name": "payout_wallet", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "payout_wallet_type", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "payout_address", + "ordinal": 14, + "type_info": "Varchar" + } + ], + "nullable": [ + false, + true, + true, + true, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + true + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT u.id, u.kratos_id, u.name, u.email, u.github_id,\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,\n u.payout_address\n FROM users u\n WHERE LOWER(u.username) = LOWER($1)\n " + }, "d203b99bd23d16224348e4fae44296aa0e1ea6d6a3fac26908303069b36a8dd0": { "describe": { "columns": [], @@ -5503,6 +5869,25 @@ }, "query": "\n UPDATE mods\n SET moderation_message = $1\n WHERE (id = $2)\n " }, + "d3756551348a3a4340cb460cf486229c4e3845d17dd765ff0bf7c1b9409c1770": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz" + ] + } + }, + "query": "\n INSERT INTO users (\n id, kratos_id, username, name, email,\n avatar_url, bio, created\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8\n )\n " + }, "d3991923355b2e0ed7bbe6c85d9158754d7e7d28f5ac75ee5b4e782dbc5c38a9": { "describe": { "columns": [], @@ -5822,6 +6207,104 @@ }, "query": "\n SELECT id FROM mods\n WHERE slug = LOWER($1)\n " }, + "e0b0ad020deafc078ce7af9d7cb26245e00e4feaf8fcae12829113c7c04792cf": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "email", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "kratos_id", + "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" + } + ], + "nullable": [ + false, + true, + true, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT u.id, u.name, u.email, u.kratos_id,\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,\n u.payout_address\n FROM users u\n WHERE u.github_id = $1\n " + }, "e29da865af4a0a110275b9756394546a3bb88bff40e18c66029651f515caed98": { "describe": { "columns": [ @@ -6152,7 +6635,7 @@ }, "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n " }, - "ebb79a02f5471cf664735e42e039066cd9a5ed7ccef69d3d3d5ad037a44e77dc": { + "ebbd2105c456a9b462a4e5ace356345ff18ad6cbcdfc053afe86948f8f3ae092": { "describe": { "columns": [ { @@ -6161,136 +6644,41 @@ "type_info": "Int8" }, { - "name": "team_id", + "name": "name", "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, + "name": "scope", + "ordinal": 2, "type_info": "Int8" }, { "name": "user_id", - "ordinal": 7, + "ordinal": 3, "type_info": "Int8" }, { - "name": "github_id", - "ordinal": 8, - "type_info": "Int8" - }, - { - "name": "user_name", - "ordinal": 9, - "type_info": "Varchar" - }, - { - "name": "email", - "ordinal": 10, - "type_info": "Varchar" - }, - { - "name": "avatar_url", - "ordinal": 11, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 12, - "type_info": "Varchar" - }, - { - "name": "bio", - "ordinal": 13, - "type_info": "Varchar" - }, - { - "name": "created", - "ordinal": 14, - "type_info": "Timestamptz" - }, - { - "name": "user_role", - "ordinal": 15, - "type_info": "Varchar" - }, - { - "name": "badges", - "ordinal": 16, - "type_info": "Int8" - }, - { - "name": "balance", - "ordinal": 17, - "type_info": "Numeric" - }, - { - "name": "payout_wallet", - "ordinal": 18, - "type_info": "Varchar" - }, - { - "name": "payout_wallet_type", - "ordinal": 19, - "type_info": "Varchar" - }, - { - "name": "payout_address", - "ordinal": 20, - "type_info": "Varchar" + "name": "expires_at", + "ordinal": 4, + "type_info": "Timestamp" } ], "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - true, - true, false, true, false, false, - false, - false, - true, - true, - true + false ], "parameters": { "Left": [ - "Int8Array" + "Int8", + "Int8" ] } }, - "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.github_id github_id, u.name user_name, u.email email,\n u.avatar_url avatar_url, u.username username, u.bio bio,\n u.created created, u.role user_role, u.badges badges, u.balance balance,\n u.payout_wallet payout_wallet, u.payout_wallet_type payout_wallet_type,\n u.payout_address payout_address\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 SELECT id, name, scope, user_id, expires_at FROM pats\n WHERE id = $1 AND user_id = $2\n " }, "ebef881a0dae70e990814e567ed3de9565bb29b772782bc974c953af195fd6d7": { "describe": { @@ -6772,5 +7160,17 @@ } }, "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/database/models/ids.rs b/src/database/models/ids.rs index 66e4e2c99..98a4329f3 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -83,6 +83,14 @@ 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_user_id, UserId, @@ -177,6 +185,10 @@ pub struct FileId(pub i64); #[sqlx(transparent)] pub struct StateId(pub i64); +#[derive(Copy, Clone, Debug, Type)] +#[sqlx(transparent)] +pub struct PatId(pub i64); + #[derive(Copy, Clone, Debug, Type, Deserialize)] #[sqlx(transparent)] pub struct NotificationId(pub i64); diff --git a/src/database/models/team_item.rs b/src/database/models/team_item.rs index 3a365606d..ef1d1156a 100644 --- a/src/database/models/team_item.rs +++ b/src/database/models/team_item.rs @@ -128,7 +128,7 @@ impl TeamMember { let teams = 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.github_id github_id, u.name user_name, u.email email, + u.id user_id, u.name user_name, u.email email, u.kratos_id kratos_id, u.github_id github_id, u.avatar_url avatar_url, u.username username, u.bio bio, u.created created, u.role user_role, u.badges badges, u.balance balance, u.payout_wallet payout_wallet, u.payout_wallet_type payout_wallet_type, @@ -153,6 +153,7 @@ impl TeamMember { user: User { id: UserId(m.user_id), github_id: m.github_id, + kratos_id: m.kratos_id, name: m.user_name, email: m.email, avatar_url: m.avatar_url, diff --git a/src/database/models/user_item.rs b/src/database/models/user_item.rs index 2ccf38146..97e561f48 100644 --- a/src/database/models/user_item.rs +++ b/src/database/models/user_item.rs @@ -5,6 +5,7 @@ use rust_decimal::Decimal; pub struct User { pub id: UserId, + pub kratos_id: Option, // None if legacy user unconnected to Minos/Kratos pub github_id: Option, pub username: String, pub name: Option, @@ -28,7 +29,7 @@ impl User { sqlx::query!( " INSERT INTO users ( - id, github_id, username, name, email, + id, kratos_id, username, name, email, avatar_url, bio, created ) VALUES ( @@ -37,7 +38,7 @@ impl User { ) ", self.id as UserId, - self.github_id, + self.kratos_id, &self.username, self.name.as_ref(), self.email.as_ref(), @@ -50,6 +51,7 @@ impl User { Ok(()) } + pub async fn get<'a, 'b, E>(id: UserId, executor: E) -> Result, sqlx::error::Error> where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, @@ -68,7 +70,7 @@ impl User { { let result = sqlx::query!( " - SELECT u.id, u.name, u.email, + SELECT u.id, u.name, u.email, u.kratos_id, u.avatar_url, u.username, u.bio, u.created, u.role, u.badges, u.balance, u.payout_wallet, u.payout_wallet_type, @@ -87,6 +89,54 @@ impl User { github_id: Some(github_id as i64), name: row.name, email: row.email, + kratos_id: row.kratos_id, + 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, + })) + } else { + Ok(None) + } + } + + pub async fn get_from_minos_kratos_id<'a, 'b, E>( + kratos_id: String, + executor: E, + ) -> Result, sqlx::error::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT u.id, u.name, u.kratos_id, u.email, u.github_id, + 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 + FROM users u + WHERE u.kratos_id = $1 + ", + kratos_id as String, + ) + .fetch_optional(executor) + .await?; + + if let Some(row) = result { + Ok(Some(User { + id: UserId(row.id), + kratos_id: row.kratos_id, + github_id: row.github_id, + name: row.name, + email: row.email, avatar_url: row.avatar_url, username: row.username, bio: row.bio, @@ -114,7 +164,7 @@ impl User { { let result = sqlx::query!( " - SELECT u.id, u.github_id, u.name, u.email, + SELECT u.id, u.kratos_id, u.name, u.email, u.github_id, u.avatar_url, u.username, u.bio, u.created, u.role, u.badges, u.balance, u.payout_wallet, u.payout_wallet_type, @@ -130,6 +180,7 @@ impl User { if let Some(row) = result { Ok(Some(User { id: UserId(row.id), + kratos_id: row.kratos_id, github_id: row.github_id, name: row.name, email: row.email, @@ -160,7 +211,7 @@ impl User { let user_ids_parsed: Vec = user_ids.iter().map(|x| x.0).collect(); let users = sqlx::query!( " - SELECT u.id, u.github_id, u.name, u.email, + SELECT u.id, u.kratos_id, u.name, u.email, u.github_id, u.avatar_url, u.username, u.bio, u.created, u.role, u.badges, u.balance, u.payout_wallet, u.payout_wallet_type, @@ -174,6 +225,7 @@ impl User { .try_filter_map(|e| async { Ok(e.right().map(|u| User { id: UserId(u.id), + kratos_id: u.kratos_id, github_id: u.github_id, name: u.name, email: u.email, @@ -514,4 +566,28 @@ impl User { Ok(id.map(|x| UserId(x.id))) } } + + pub async fn merge_minos_user<'a, 'b, E>( + &self, + kratos_id: &str, + executor: E, + ) -> Result<(), sqlx::error::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + // If the user exists, link the Minos user into the existing user rather tham create a new one + sqlx::query!( + " + UPDATE users + SET kratos_id = $1 + WHERE (id = $2) + ", + kratos_id, + self.id.0, + ) + .execute(executor) + .await?; + + Ok(()) + } } diff --git a/src/main.rs b/src/main.rs index 733982210..2a9b804bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -387,6 +387,9 @@ fn check_env_vars() -> bool { failed |= check_var::("SITE_URL"); failed |= check_var::("CDN_URL"); + failed |= check_var::("MINOS_URL"); + failed |= check_var::("KRATOS_URL"); + failed |= check_var::("ORY_AUTH_BEARER"); failed |= check_var::("LABRINTH_ADMIN_KEY"); failed |= check_var::("RATE_LIMIT_IGNORE_KEY"); failed |= check_var::("DATABASE_URL"); diff --git a/src/models/users.rs b/src/models/users.rs index 8073c0e76..e95ac0d28 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -37,7 +37,7 @@ impl Default for Badges { #[derive(Serialize, Deserialize, Clone)] pub struct User { pub id: UserId, - pub github_id: Option, + pub kratos_id: Option, // None if legacy user unconnected to Minos/Kratos pub username: String, pub name: Option, pub email: Option, @@ -47,6 +47,12 @@ pub struct User { pub role: Role, pub badges: Badges, pub payout_data: Option, + pub github_id: Option, + pub discord_id: Option, + pub google_id: Option, + pub microsoft_id: Option, + pub apple_id: Option, + pub gitlab_id: Option, } #[derive(Serialize, Deserialize, Clone)] @@ -130,7 +136,7 @@ impl From for User { fn from(data: DBUser) -> Self { Self { id: data.id.into(), - github_id: data.github_id.map(|i| i as u64), + kratos_id: data.kratos_id, username: data.username, name: data.name, email: None, @@ -140,6 +146,12 @@ impl From for User { role: Role::from_string(&data.role), badges: data.badges, payout_data: None, + github_id: None, + discord_id: None, + google_id: None, + microsoft_id: None, + apple_id: None, + gitlab_id: None, } } } diff --git a/src/routes/v2/admin.rs b/src/routes/v2/admin.rs index 1bf18be59..2ae66475a 100644 --- a/src/routes/v2/admin.rs +++ b/src/routes/v2/admin.rs @@ -1,9 +1,12 @@ +use crate::database::models::user_item; use crate::models::ids::ProjectId; use crate::models::projects::MonetizationStatus; +use crate::models::users::User; use crate::routes::ApiError; +use crate::util::auth::{link_or_insert_new_user, MinosNewUser}; use crate::util::guards::admin_key_guard; use crate::DownloadQueue; -use actix_web::{patch, post, web, HttpResponse}; +use actix_web::{get, patch, post, web, HttpResponse}; use chrono::{DateTime, SecondsFormat, Utc}; use rust_decimal::Decimal; use serde::Deserialize; @@ -16,10 +19,38 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("admin") .service(count_download) + .service(add_minos_user) + .service(get_legacy_account) .service(process_payout), ); } +// Adds a Minos user to the database +// This is an internal endpoint, and should not be used by applications, only by the Minos backend +#[post("_minos-user-callback", guard = "admin_key_guard")] +pub async fn add_minos_user( + minos_user: web::Json, // getting directly from Kratos rather than Minos, so unparse + client: web::Data, +) -> Result { + let minos_new_user = minos_user.into_inner(); + let mut transaction = client.begin().await?; + link_or_insert_new_user(&mut transaction, minos_new_user).await?; + transaction.commit().await?; + Ok(HttpResponse::Ok().finish()) +} + +#[get("_legacy_account/{github_id}", guard = "admin_key_guard")] + +pub async fn get_legacy_account( + pool: web::Data, + github_id: web::Path, +) -> Result { + let github_id = github_id.into_inner(); + let user = user_item::User::get_from_github_id(github_id as u64, &**pool).await?; + let user: Option = user.map(|u| u.into()); + Ok(HttpResponse::Ok().json(user)) +} + #[derive(Deserialize)] pub struct DownloadBody { pub url: String, diff --git a/src/routes/v2/auth.rs b/src/routes/v2/auth.rs index f6c71534a..7589ae0f0 100644 --- a/src/routes/v2/auth.rs +++ b/src/routes/v2/auth.rs @@ -1,28 +1,30 @@ /*! -This auth module is primarily for use within the main website. Applications interacting with the -authenticated API (a very small portion - notifications, private projects, editing/creating projects -and versions) should either retrieve the Modrinth GitHub token through the site, or create a personal -app token for use with Modrinth. +This auth module is how we allow for authentication within the Modrinth sphere. +It uses a self-hosted Ory Kratos instance on the backend, powered by our Minos backend. -JUst as a summary: Don't implement this flow in your application! Instead, use a personal access token -or create your own GitHub OAuth2 application. + Applications interacting with the authenticated API (a very small portion - notifications, private projects, editing/creating projects +and versions) should include the Ory authentication cookie in their requests. This cookie is set by the Ory Kratos instance and Minos provides function to access these. -This system will be revisited and allow easier interaction with the authenticated API once we roll -out our own authentication system. +In addition, you can use a logged-in-account to generate a PAT. +This token can be passed in as a Bearer token in the Authorization header, as an alternative to a cookie. +This is useful for applications that don't have a frontend, or for applications that need to access the authenticated API on behalf of a user. + +Just as a summary: Don't implement this flow in your application! */ -use crate::database::models::{generate_state_id, User}; +use crate::database::models::{self, generate_state_id}; use crate::models::error::ApiError; use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::ids::DecodingError; -use crate::models::users::{Badges, Role}; + use crate::parse_strings_from_var; -use crate::util::auth::get_github_user_from_token; +use crate::util::auth::{get_minos_user_from_cookies, AuthenticationError}; + use actix_web::http::StatusCode; use actix_web::web::{scope, Data, Query, ServiceConfig}; -use actix_web::{get, HttpResponse}; +use actix_web::{get, HttpRequest, HttpResponse}; use chrono::Utc; -use rust_decimal::Decimal; + use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use thiserror::Error; @@ -41,8 +43,8 @@ pub enum AuthorizationError { Database(#[from] crate::database::models::DatabaseError), #[error("Error while parsing JSON: {0}")] SerDe(#[from] serde_json::Error), - #[error("Error while communicating to GitHub OAuth2")] - Github(#[from] reqwest::Error), + #[error("Error with communicating to Minos")] + Minos(#[from] reqwest::Error), #[error("Invalid Authentication credentials")] InvalidCredentials, #[error("Authentication Error: {0}")] @@ -51,8 +53,8 @@ pub enum AuthorizationError { Decoding(#[from] DecodingError), #[error("Invalid callback URL specified")] Url, - #[error("User is not allowed to access Modrinth services")] - Banned, + #[error("User exists in Minos but not in Labrinth")] + DatabaseMismatch, } impl actix_web::ResponseError for AuthorizationError { fn status_code(&self) -> StatusCode { @@ -61,12 +63,12 @@ impl actix_web::ResponseError for AuthorizationError { AuthorizationError::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR, AuthorizationError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR, AuthorizationError::SerDe(..) => StatusCode::BAD_REQUEST, - AuthorizationError::Github(..) => StatusCode::FAILED_DEPENDENCY, + AuthorizationError::Minos(..) => StatusCode::INTERNAL_SERVER_ERROR, AuthorizationError::InvalidCredentials => StatusCode::UNAUTHORIZED, AuthorizationError::Decoding(..) => StatusCode::BAD_REQUEST, AuthorizationError::Authentication(..) => StatusCode::UNAUTHORIZED, AuthorizationError::Url => StatusCode::BAD_REQUEST, - AuthorizationError::Banned => StatusCode::FORBIDDEN, + AuthorizationError::DatabaseMismatch => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -77,12 +79,12 @@ impl actix_web::ResponseError for AuthorizationError { AuthorizationError::SqlxDatabase(..) => "database_error", AuthorizationError::Database(..) => "database_error", AuthorizationError::SerDe(..) => "invalid_input", - AuthorizationError::Github(..) => "github_error", + AuthorizationError::Minos(..) => "network_error", AuthorizationError::InvalidCredentials => "invalid_credentials", AuthorizationError::Decoding(..) => "decoding_error", AuthorizationError::Authentication(..) => "authentication_error", AuthorizationError::Url => "url_error", - AuthorizationError::Banned => "user_banned", + AuthorizationError::DatabaseMismatch => "database_mismatch", }, description: &self.to_string(), }) @@ -93,31 +95,22 @@ impl actix_web::ResponseError for AuthorizationError { pub struct AuthorizationInit { pub url: String, } - #[derive(Serialize, Deserialize)] -pub struct Authorization { - pub code: String, +pub struct StateResponse { pub state: String, } -#[derive(Serialize, Deserialize)] -pub struct AccessToken { - pub access_token: String, - pub scope: String, - pub token_type: String, -} - -//http://localhost:8000/api/v1/auth/init?url=https%3A%2F%2Fmodrinth.com%2Fmods +// Init link takes us to Minos API and calls back to callback endpoint with a code and state +//http://:8000/api/v1/auth/init?url=https%3A%2F%2Fmodrinth.com%2Fmods #[get("init")] pub async fn init( - Query(info): Query, + Query(info): Query, // callback url client: Data, ) -> Result { let url = url::Url::parse(&info.url).map_err(|_| AuthorizationError::Url)?; let allowed_callback_urls = parse_strings_from_var("ALLOWED_CALLBACK_URLS").unwrap_or_default(); - - let domain = url.domain().ok_or(AuthorizationError::Url)?; + let domain = url.host_str().ok_or(AuthorizationError::Url)?; // TODO: change back to .domain() (host_str is so we can use 127.0.0.1) if !allowed_callback_urls.iter().any(|x| domain.ends_with(x)) && domain != "modrinth.com" { return Err(AuthorizationError::Url); } @@ -139,14 +132,13 @@ pub async fn init( transaction.commit().await?; - let client_id = dotenvy::var("GITHUB_CLIENT_ID")?; + let kratos_url = dotenvy::var("KRATOS_URL")?; + let labrinth_url = dotenvy::var("BIND_ADDR")?; let url = format!( - "https://github.com/login/oauth/authorize?client_id={}&state={}&scope={}", - client_id, - to_base62(state.0 as u64), - "read%3Auser" + // Callback URL of initialization is /callback below. + "{kratos_url}/self-service/login/browser?return_to=http://{labrinth_url}/v2/auth/callback?state={}", + to_base62(state.0 as u64) ); - Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*url)) .json(AuthorizationInit { url })) @@ -154,11 +146,12 @@ pub async fn init( #[get("callback")] pub async fn auth_callback( - Query(info): Query, + req: HttpRequest, + Query(state): Query, client: Data, ) -> Result { let mut transaction = client.begin().await?; - let state_id = parse_base62(&info.state)?; + let state_id: u64 = parse_base62(&state.state)?; let result_option = sqlx::query!( " @@ -170,119 +163,51 @@ pub async fn auth_callback( .fetch_optional(&mut *transaction) .await?; + // Extract cookie header from request + let cookie_header = req.headers().get("Cookie"); if let Some(result) = result_option { - let duration: chrono::Duration = result.expires - Utc::now(); - - if duration.num_seconds() < 0 { - return Err(AuthorizationError::InvalidCredentials); - } - - sqlx::query!( - " - DELETE FROM states - WHERE id = $1 - ", - state_id as i64 - ) - .execute(&mut *transaction) - .await?; - - let client_id = dotenvy::var("GITHUB_CLIENT_ID")?; - let client_secret = dotenvy::var("GITHUB_CLIENT_SECRET")?; - - let url = format!( - "https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}", - client_id, client_secret, info.code - ); - - let token: AccessToken = reqwest::Client::new() - .post(&url) - .header(reqwest::header::ACCEPT, "application/json") - .send() - .await? - .json() + if let Some(cookie_header) = cookie_header { + // Extract cookie header to get authenticated user from Minos + let duration: chrono::Duration = result.expires - Utc::now(); + if duration.num_seconds() < 0 { + return Err(AuthorizationError::InvalidCredentials); + } + sqlx::query!( + " + DELETE FROM states + WHERE id = $1 + ", + state_id as i64 + ) + .execute(&mut *transaction) .await?; - let user = get_github_user_from_token(&token.access_token).await?; - - let user_result = User::get_from_github_id(user.id, &mut *transaction).await?; - match user_result { - Some(_) => {} - None => { - let banned_user = sqlx::query!( - "SELECT user FROM banned_users WHERE github_id = $1", - user.id as i64 - ) - .fetch_optional(&mut *transaction) - .await?; - - if banned_user.is_some() { - return Err(AuthorizationError::Banned); - } - - let user_id = crate::database::models::generate_user_id(&mut transaction).await?; - - let mut username_increment: i32 = 0; - let mut username = None; - - while username.is_none() { - let test_username = format!( - "{}{}", - &user.login, - if username_increment > 0 { - username_increment.to_string() - } else { - "".to_string() - } - ); - - let new_id = crate::database::models::User::get_id_from_username_or_id( - &test_username, - &**client, - ) + // Attempt to create a minos user from the cookie header- if this fails, the user is invalid + let minos_user = get_minos_user_from_cookies( + cookie_header + .to_str() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + .await?; + let user_result = + models::User::get_from_minos_kratos_id(minos_user.id.clone(), &mut transaction) .await?; - if new_id.is_none() { - username = Some(test_username); - } else { - username_increment += 1; - } - } - - if let Some(username) = username { - User { - id: user_id, - github_id: Some(user.id as i64), - username, - name: user.name, - email: user.email, - avatar_url: Some(user.avatar_url), - bio: user.bio, - created: Utc::now(), - role: Role::Developer.to_string(), - badges: Badges::default(), - balance: Decimal::ZERO, - payout_wallet: None, - payout_wallet_type: None, - payout_address: None, - } - .insert(&mut transaction) - .await?; - } + // Cookies exist, but user does not exist in database, meaning they are invalid + if user_result.is_none() { + return Err(AuthorizationError::DatabaseMismatch); } - } + transaction.commit().await?; - transaction.commit().await?; - - let redirect_url = if result.url.contains('?') { - format!("{}&code={}", result.url, token.access_token) + // Cookie is attached now, so redirect to the original URL + // Do not re-append cookie header, as it is not needed, + // because all redirects are to various modrinth.com subdomains + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*result.url)) + .json(AuthorizationInit { url: result.url })) } else { - format!("{}?code={}", result.url, token.access_token) - }; - - Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*redirect_url)) - .json(AuthorizationInit { url: redirect_url })) + Err(AuthorizationError::InvalidCredentials) + } } else { Err(AuthorizationError::InvalidCredentials) } diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index 8386321c2..1318e0e7d 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -3,6 +3,7 @@ mod auth; mod midas; mod moderation; mod notifications; +mod pats; pub(crate) mod project_creation; mod projects; mod reports; @@ -25,6 +26,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(midas::config) .configure(moderation::config) .configure(notifications::config) + .configure(pats::config) .configure(project_creation::config) .configure(projects::config) .configure(reports::config) diff --git a/src/routes/v2/moderation.rs b/src/routes/v2/moderation.rs index bc7fa228d..d69205c69 100644 --- a/src/routes/v2/moderation.rs +++ b/src/routes/v2/moderation.rs @@ -2,17 +2,12 @@ use super::ApiError; use crate::database; use crate::models::projects::ProjectStatus; use crate::util::auth::check_is_moderator_from_headers; -use actix_web::{delete, get, web, HttpRequest, HttpResponse}; +use actix_web::{get, web, HttpRequest, HttpResponse}; use serde::Deserialize; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("moderation") - .service(get_projects) - .service(ban_user) - .service(unban_user), - ); + cfg.service(web::scope("moderation").service(get_projects)); } #[derive(Deserialize)] @@ -58,38 +53,3 @@ pub async fn get_projects( Ok(HttpResponse::Ok().json(projects)) } - -#[derive(Deserialize)] -pub struct BanUser { - pub id: i64, -} - -#[get("ban")] -pub async fn ban_user( - req: HttpRequest, - pool: web::Data, - id: web::Query, -) -> Result { - check_is_moderator_from_headers(req.headers(), &**pool).await?; - - sqlx::query!("INSERT INTO banned_users (github_id) VALUES ($1);", id.id) - .execute(&**pool) - .await?; - - Ok(HttpResponse::NoContent().body("")) -} - -#[delete("ban")] -pub async fn unban_user( - req: HttpRequest, - pool: web::Data, - id: web::Query, -) -> Result { - check_is_moderator_from_headers(req.headers(), &**pool).await?; - - sqlx::query!("DELETE FROM banned_users WHERE github_id = $1;", id.id) - .execute(&**pool) - .await?; - - Ok(HttpResponse::NoContent().body("")) -} diff --git a/src/routes/v2/pats.rs b/src/routes/v2/pats.rs new file mode 100644 index 000000000..59636189d --- /dev/null +++ b/src/routes/v2/pats.rs @@ -0,0 +1,233 @@ +/*! +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::models::users::UserId; +use crate::routes::ApiError; +use crate::util::auth::get_user_from_headers; +use crate::util::pat::{generate_pat, PersonalAccessToken}; + +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) -> Result { + let user: crate::models::users::User = get_user_from_headers(req.headers(), &**pool).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 "mod_" +#[post("pat")] +pub async fn create_pat( + req: HttpRequest, + Query(info): Query, // callback url + pool: Data, +) -> Result { + let user: crate::models::users::User = get_user_from_headers(req.headers(), &**pool).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, +) -> Result { + let user: crate::models::users::User = get_user_from_headers(req.headers(), &**pool).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, +) -> Result { + let user: crate::models::users::User = get_user_from_headers(req.headers(), &**pool).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 f7096a4bd..1e10ba29a 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -10,7 +10,7 @@ use crate::models::projects::{ use crate::models::threads::ThreadType; use crate::models::users::UserId; use crate::search::indexing::IndexingError; -use crate::util::auth::{get_user_from_headers, AuthenticationError}; +use crate::util::auth::{get_user_from_headers_transaction, AuthenticationError}; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use actix_multipart::{Field, Multipart}; @@ -341,7 +341,7 @@ async fn project_create_inner( let cdn_url = dotenvy::var("CDN_URL")?; // The currently logged in user - let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?; + let current_user = get_user_from_headers_transaction(req.headers(), &mut *transaction).await?; let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); diff --git a/src/routes/v2/reports.rs b/src/routes/v2/reports.rs index e75f82ce2..63bdd8fa4 100644 --- a/src/routes/v2/reports.rs +++ b/src/routes/v2/reports.rs @@ -3,7 +3,9 @@ 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::routes::ApiError; -use crate::util::auth::{check_is_moderator_from_headers, get_user_from_headers}; +use crate::util::auth::{ + check_is_moderator_from_headers, get_user_from_headers, get_user_from_headers_transaction, +}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::Utc; use futures::StreamExt; @@ -36,7 +38,7 @@ pub async fn report_create( ) -> Result { let mut transaction = pool.begin().await?; - let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?; + let current_user = get_user_from_headers_transaction(req.headers(), &mut transaction).await?; let mut bytes = web::BytesMut::new(); while let Some(item) = body.next().await { diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 7ef338df3..bed9eff50 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -12,7 +12,7 @@ use crate::models::projects::{ VersionId, VersionStatus, VersionType, }; use crate::models::teams::Permissions; -use crate::util::auth::get_user_from_headers; +use crate::util::auth::get_user_from_headers_transaction; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use crate::validate::{validate_file, ValidationResult}; @@ -127,7 +127,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(), &mut *transaction).await?; + let user = get_user_from_headers_transaction(req.headers(), &mut *transaction).await?; let mut error = None; while let Some(item) = payload.next().await { @@ -479,7 +479,7 @@ async fn upload_file_to_version_inner( let mut initial_file_data: Option = None; let mut file_builders: Vec = Vec::new(); - let user = get_user_from_headers(req.headers(), &mut *transaction).await?; + let user = get_user_from_headers_transaction(req.headers(), &mut *transaction).await?; let result = models::Version::get_full(version_id, &**client).await?; diff --git a/src/util/auth.rs b/src/util/auth.rs index c78681312..30b863cf7 100644 --- a/src/util/auth.rs +++ b/src/util/auth.rs @@ -1,15 +1,24 @@ use crate::database; use crate::database::models::project_item::QueryProject; +use crate::database::models::user_item; use crate::database::models::version_item::QueryVersion; use crate::database::{models, Project, Version}; -use crate::models::users::{Role, User, UserId, UserPayoutData}; +use crate::models::users::{Badges, Role, User, UserId, UserPayoutData}; use crate::routes::ApiError; +use crate::Utc; use actix_web::http::header::HeaderMap; +use actix_web::http::header::COOKIE; use actix_web::web; +use reqwest::header::{HeaderValue, AUTHORIZATION}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use serde_with::DisplayFromStr; use sqlx::PgPool; use thiserror::Error; +use super::pat::get_user_from_pat; + #[derive(Error, Debug)] pub enum AuthenticationError { #[error("An unknown database error occurred")] @@ -18,85 +27,323 @@ pub enum AuthenticationError { Database(#[from] models::DatabaseError), #[error("Error while parsing JSON: {0}")] SerDe(#[from] serde_json::Error), - #[error("Error while communicating to GitHub OAuth2: {0}")] - Github(#[from] reqwest::Error), + #[error("Error while communicating over the internet: {0}")] + Reqwest(#[from] reqwest::Error), + #[error("Error while decoding PAT: {0}")] + Decoding(#[from] crate::models::ids::DecodingError), #[error("Invalid Authentication Credentials")] InvalidCredentials, + #[error("Authentication method was not valid")] + InvalidAuthMethod, } +// A user as stored in the Minos database #[derive(Serialize, Deserialize, Debug)] -pub struct GitHubUser { - pub login: String, - pub id: u64, - pub avatar_url: String, - pub name: Option, - pub email: Option, - pub bio: Option, +pub struct MinosUser { + pub id: String, // This is the unique generated Ory name + pub username: String, // unique username + pub email: String, + pub name: Option, // real name + pub github_id: Option, + pub discord_id: Option, + pub google_id: Option, + pub gitlab_id: Option, + pub microsoft_id: Option, + pub apple_id: Option, } -pub async fn get_github_user_from_token( - access_token: &str, -) -> Result { - Ok(reqwest::Client::new() - .get("https://api.github.com/user") - .header(reqwest::header::USER_AGENT, "Modrinth") +// A payload marking a new user in Minos, with data to be inserted into Labrinth +#[serde_as] +#[derive(Deserialize, Debug)] +pub struct MinosNewUser { + pub id: String, // This is the unique generated Ory name + pub username: String, // unique username + pub email: String, + + pub name: Option, // real name + pub default_picture: Option, // uri of default avatar + #[serde_as(as = "Option")] + pub github_id: Option, // we allow Github to be submitted to connect to an existing account +} + +// Attempt to append a Minos user to an existing user, if one exists +// (combining the the legacy user with the Minos user) +pub async fn link_or_insert_new_user( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + minos_new_user: MinosNewUser, +) -> Result<(), AuthenticationError> { + // If the user with this Github ID already exists, we can just merge the two accounts + if let Some(github_id) = minos_new_user.github_id { + if let Some(existing_user) = + user_item::User::get_from_github_id(github_id as u64, &mut *transaction).await? + { + existing_user + .merge_minos_user(&minos_new_user.id, &mut *transaction) + .await?; + return Ok(()); + } + } + // No user exists, so we need to create a new user + insert_new_user(transaction, minos_new_user).await?; + + Ok(()) +} + +// Insert a new user into the database from a MinosUser +pub async fn insert_new_user( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + minos_new_user: MinosNewUser, +) -> Result<(), AuthenticationError> { + let user_id = crate::database::models::generate_user_id(transaction).await?; + + database::models::User { + id: user_id, + kratos_id: Some(minos_new_user.id), + username: minos_new_user.username, + name: minos_new_user.name, + email: Some(minos_new_user.email), + avatar_url: minos_new_user.default_picture, + bio: None, + github_id: minos_new_user.github_id, + 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(transaction) + .await?; + + Ok(()) +} + +// Gets MinosUser from Kratos ID +// This uses an administrative bearer token to access the Minos API +// Should NOT be directly accessible to users +pub async fn get_minos_user(kratos_id: &str) -> Result { + let ory_auth_bearer = dotenvy::var("ORY_AUTH_BEARER").unwrap(); + let req = reqwest::Client::new() + .get(format!( + "{}/admin/user/{kratos_id}", + dotenvy::var("MINOS_URL").unwrap() + )) + .header(reqwest::header::USER_AGENT, "Labrinth") .header( reqwest::header::AUTHORIZATION, - format!("token {access_token}"), - ) - .send() - .await? - .json() - .await?) + format!("Bearer {ory_auth_bearer}"), + ); + let res = req.send().await?.error_for_status()?; + let res = res.json().await?; + Ok(res) } -pub async fn get_user_from_token<'a, 'b, E>( - access_token: &str, - executor: E, -) -> Result -where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, -{ - let github_user = get_github_user_from_token(access_token).await?; +// pass the cookies to Minos to get the user. +pub async fn get_minos_user_from_cookies(cookies: &str) -> Result { + let req = reqwest::Client::new() + .get(dotenvy::var("MINOS_URL").unwrap() + "/user") + .header(reqwest::header::USER_AGENT, "Modrinth") + .header(reqwest::header::COOKIE, cookies); + let res = req.send().await?; - let res = models::User::get_from_github_id(github_user.id, executor).await?; - - match res { - Some(result) => Ok(User { - id: UserId::from(result.id), - github_id: result.github_id.map(|i| i as u64), - username: result.username, - name: result.name, - email: result.email, - avatar_url: result.avatar_url, - bio: result.bio, - created: result.created, - role: Role::from_string(&result.role), - badges: result.badges, - payout_data: Some(UserPayoutData { - balance: result.balance, - payout_wallet: result.payout_wallet, - payout_wallet_type: result.payout_wallet_type, - payout_address: result.payout_address, - }), - }), - None => Err(AuthenticationError::InvalidCredentials), - } + let res = match res.status() { + reqwest::StatusCode::OK => res, + reqwest::StatusCode::UNAUTHORIZED => return Err(AuthenticationError::InvalidCredentials), + _ => res.error_for_status()?, + }; + Ok(res.json().await?) } -pub async fn get_user_from_headers<'a, 'b, E>( + +pub async fn get_user_from_headers<'a, E>( headers: &HeaderMap, executor: E, ) -> Result +where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + let token: Option<&reqwest::header::HeaderValue> = headers.get(AUTHORIZATION); + let cookies_unparsed: Option<&reqwest::header::HeaderValue> = headers.get(COOKIE); + + // Fetch DB user record and minos user from headers + let (db_user, minos_user) = match (token, cookies_unparsed) { + // If both, favour the bearer token first- redirect to cookie on failure + (Some(token), Some(cookies)) => { + match get_db_and_minos_user_from_bearer_token(token, executor).await { + Ok((db, minos)) => (db, minos), + Err(_) => get_db_and_minos_user_from_cookies(cookies, executor).await?, + } + } + (Some(token), _) => get_db_and_minos_user_from_bearer_token(token, executor).await?, + (_, Some(cookies)) => get_db_and_minos_user_from_cookies(cookies, executor).await?, + _ => return Err(AuthenticationError::InvalidAuthMethod), // No credentials passed + }; + + let user = User { + id: UserId::from(db_user.id), + kratos_id: db_user.kratos_id, + github_id: minos_user.github_id, + discord_id: minos_user.discord_id, + google_id: minos_user.google_id, + microsoft_id: minos_user.microsoft_id, + apple_id: minos_user.apple_id, + gitlab_id: minos_user.gitlab_id, + username: db_user.username, + name: db_user.name, + email: db_user.email, + avatar_url: db_user.avatar_url, + bio: db_user.bio, + created: db_user.created, + role: Role::from_string(&db_user.role), + badges: db_user.badges, + payout_data: Some(UserPayoutData { + balance: db_user.balance, + payout_wallet: db_user.payout_wallet, + payout_wallet_type: db_user.payout_wallet_type, + payout_address: db_user.payout_address, + }), + }; + Ok(user) +} + +pub async fn get_user_from_headers_transaction( + headers: &HeaderMap, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + let token: Option<&reqwest::header::HeaderValue> = headers.get(AUTHORIZATION); + let cookies_unparsed: Option<&reqwest::header::HeaderValue> = headers.get(COOKIE); + + // Fetch DB user record and minos user from headers + let (db_user, minos_user) = match (token, cookies_unparsed) { + // If both, favour the bearer token first- redirect to cookie on failure + (Some(token), Some(cookies)) => { + match get_db_and_minos_user_from_bearer_token(token, &mut *transaction).await { + Ok((db, minos)) => (db, minos), + Err(_) => get_db_and_minos_user_from_cookies(cookies, &mut *transaction).await?, + } + } + (Some(token), _) => { + get_db_and_minos_user_from_bearer_token(token, &mut *transaction).await? + } + (_, Some(cookies)) => { + get_db_and_minos_user_from_cookies(cookies, &mut *transaction).await? + } + _ => return Err(AuthenticationError::InvalidAuthMethod), // No credentials passed + }; + + let user = User { + id: UserId::from(db_user.id), + kratos_id: db_user.kratos_id, + github_id: minos_user.github_id, + discord_id: minos_user.discord_id, + google_id: minos_user.google_id, + microsoft_id: minos_user.microsoft_id, + apple_id: minos_user.apple_id, + gitlab_id: minos_user.gitlab_id, + username: db_user.username, + name: db_user.name, + email: db_user.email, + avatar_url: db_user.avatar_url, + bio: db_user.bio, + created: db_user.created, + role: Role::from_string(&db_user.role), + badges: db_user.badges, + payout_data: Some(UserPayoutData { + balance: db_user.balance, + payout_wallet: db_user.payout_wallet, + payout_wallet_type: db_user.payout_wallet_type, + payout_address: db_user.payout_address, + }), + }; + Ok(user) +} + +pub async fn get_db_and_minos_user_from_bearer_token<'a, E>( + token: &HeaderValue, + executor: E, +) -> Result<(user_item::User, MinosUser), AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - let token = headers - .get("Authorization") - .ok_or(AuthenticationError::InvalidCredentials)? - .to_str() - .map_err(|_| AuthenticationError::InvalidCredentials)?; + let db_user = get_user_record_from_bearer_token( + token + .to_str() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + executor, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let minos_user = get_minos_user( + &db_user + .kratos_id + .clone() + .ok_or_else(|| AuthenticationError::InvalidCredentials)?, + ) + .await?; + Ok((db_user, minos_user)) +} - get_user_from_token(token, executor).await +pub async fn get_db_and_minos_user_from_cookies<'a, E>( + cookies: &HeaderValue, + executor: E, +) -> Result<(user_item::User, MinosUser), AuthenticationError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, +{ + let minos_user = get_minos_user_from_cookies( + cookies + .to_str() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + .await?; + let db_user = models::User::get_from_minos_kratos_id(minos_user.id.clone(), executor) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + Ok((db_user, minos_user)) +} + +pub async fn get_user_record_from_bearer_token<'a, 'b, E>( + token: &str, + executor: E, +) -> Result, AuthenticationError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, +{ + if token.starts_with("Bearer ") { + let token: &str = token.trim_start_matches("Bearer "); + + // Tokens beginning with Ory are considered to be Kratos tokens (in reality, extracted cookies) and can be forwarded to Minos + let possible_user = match token.split_at(4) { + ("mod_", _) => get_user_from_pat(token, executor).await?, + ("ory_", _) => get_user_from_minos_session_token(token, executor).await?, + _ => return Err(AuthenticationError::InvalidAuthMethod), + }; + Ok(possible_user) + } else { + Err(AuthenticationError::InvalidAuthMethod) + } +} + +pub async fn get_user_from_minos_session_token<'a, 'b, E>( + token: &str, + executor: E, +) -> Result, AuthenticationError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, +{ + let ory_auth_bearer = dotenvy::var("ORY_AUTH_BEARER").unwrap(); + let req = reqwest::Client::new() + .get(dotenvy::var("MINOS_URL").unwrap() + "/admin/user/token?token=" + token) + .header(reqwest::header::USER_AGENT, "Labrinth") + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {ory_auth_bearer}"), + ); + let res = req.send().await?.error_for_status()?; + let minos_user: MinosUser = res.json().await?; + + let db_user = models::User::get_from_minos_kratos_id(minos_user.id.clone(), executor).await?; + Ok(db_user) } pub async fn check_is_moderator_from_headers<'a, 'b, E>( @@ -104,7 +351,7 @@ pub async fn check_is_moderator_from_headers<'a, 'b, E>( executor: E, ) -> Result where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { let user = get_user_from_headers(headers, executor).await?; diff --git a/src/util/mod.rs b/src/util/mod.rs index 46456c617..15ad2479d 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -3,6 +3,7 @@ pub mod env; pub mod ext; pub mod guards; pub mod img; +pub mod pat; pub mod routes; pub mod validate; pub mod webhook; diff --git a/src/util/pat.rs b/src/util/pat.rs new file mode 100644 index 000000000..f052d0375 --- /dev/null +++ b/src/util/pat.rs @@ -0,0 +1,118 @@ +/*! +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 super::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.kratos_id, u.email, u.github_id, + 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 + 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), + kratos_id: row.kratos_id, + name: row.name, + github_id: row.github_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 'mod_' +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("mod_"); + for _ in 0..60 { + let c = rng.gen_range(0..60); + 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); + } + } +}