diff --git a/apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json b/apps/labrinth/.sqlx/query-2021fcddfb0df923e4c7229e59ded6352f8502009aa619b1f46f2592ad5de9f3.json similarity index 71% rename from apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json rename to apps/labrinth/.sqlx/query-2021fcddfb0df923e4c7229e59ded6352f8502009aa619b1f46f2592ad5de9f3.json index 506d54fda7..f6fbadb8e1 100644 --- a/apps/labrinth/.sqlx/query-3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6.json +++ b/apps/labrinth/.sqlx/query-2021fcddfb0df923e4c7229e59ded6352f8502009aa619b1f46f2592ad5de9f3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", + "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n\t\t\t\t\t\tcreated, role, badges, account_locked,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n\t\t\t\t\t", "describe": { "columns": [ { @@ -50,96 +50,101 @@ }, { "ordinal": 9, + "name": "account_locked", + "type_info": "Bool" + }, + { + "ordinal": 10, "name": "campaign_pride_26_last_donated_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "campaign_pride_26_total_amount_donated_usd", "type_info": "Numeric" }, { - "ordinal": 11, + "ordinal": 12, "name": "github_id", "type_info": "Int8" }, { - "ordinal": 12, + "ordinal": 13, "name": "discord_id", "type_info": "Int8" }, { - "ordinal": 13, + "ordinal": 14, "name": "gitlab_id", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 15, "name": "google_id", "type_info": "Varchar" }, { - "ordinal": 15, + "ordinal": 16, "name": "steam_id", "type_info": "Int8" }, { - "ordinal": 16, + "ordinal": 17, "name": "microsoft_id", "type_info": "Varchar" }, { - "ordinal": 17, + "ordinal": 18, "name": "email_verified", "type_info": "Bool" }, { - "ordinal": 18, + "ordinal": 19, "name": "password", "type_info": "Text" }, { - "ordinal": 19, + "ordinal": 20, "name": "totp_secret", "type_info": "Varchar" }, { - "ordinal": 20, + "ordinal": 21, "name": "paypal_id", "type_info": "Text" }, { - "ordinal": 21, + "ordinal": 22, "name": "paypal_country", "type_info": "Text" }, { - "ordinal": 22, + "ordinal": 23, "name": "paypal_email", "type_info": "Text" }, { - "ordinal": 23, + "ordinal": 24, "name": "venmo_handle", "type_info": "Text" }, { - "ordinal": 24, + "ordinal": 25, "name": "stripe_customer_id", "type_info": "Text" }, { - "ordinal": 25, + "ordinal": 26, "name": "allow_friend_requests", "type_info": "Bool" }, { - "ordinal": 26, + "ordinal": 27, "name": "is_subscribed_to_newsletter", "type_info": "Bool" }, { - "ordinal": 27, + "ordinal": 28, "name": "eligibility_verified_at", "type_info": "Timestamptz" } @@ -160,6 +165,7 @@ false, false, false, + false, null, null, true, @@ -181,5 +187,5 @@ true ] }, - "hash": "3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6" + "hash": "2021fcddfb0df923e4c7229e59ded6352f8502009aa619b1f46f2592ad5de9f3" } diff --git a/apps/labrinth/.sqlx/query-8496416e98bbb63db17c89e0fe85b0b58a90a2e5252d4f54943fe640863c47a3.json b/apps/labrinth/.sqlx/query-8496416e98bbb63db17c89e0fe85b0b58a90a2e5252d4f54943fe640863c47a3.json new file mode 100644 index 0000000000..47715c2606 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8496416e98bbb63db17c89e0fe85b0b58a90a2e5252d4f54943fe640863c47a3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\t\t\tUPDATE users SET account_locked = $1 WHERE id = $2\n\t\t\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8496416e98bbb63db17c89e0fe85b0b58a90a2e5252d4f54943fe640863c47a3" +} diff --git a/apps/labrinth/.sqlx/query-6b881555e610ddc6796cdcbfd2de26e68b10522d0f1df3f006d58f6b72be9911.json b/apps/labrinth/.sqlx/query-a120bab0d2d0f721bade7fdb7a4854aed5d31564ec8896635f81ded9b02153d7.json similarity index 64% rename from apps/labrinth/.sqlx/query-6b881555e610ddc6796cdcbfd2de26e68b10522d0f1df3f006d58f6b72be9911.json rename to apps/labrinth/.sqlx/query-a120bab0d2d0f721bade7fdb7a4854aed5d31564ec8896635f81ded9b02153d7.json index 797214d3b2..b59305ead1 100644 --- a/apps/labrinth/.sqlx/query-6b881555e610ddc6796cdcbfd2de26e68b10522d0f1df3f006d58f6b72be9911.json +++ b/apps/labrinth/.sqlx/query-a120bab0d2d0f721bade7fdb7a4854aed5d31564ec8896635f81ded9b02153d7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO oauth_access_tokens (\n id, authorization_id, token_hash, scopes, last_used\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n RETURNING created, expires\n ", + "query": "\n INSERT INTO oauth_access_tokens (\n id, authorization_id, token_hash, scopes, last_used\n )\n\t\t\tSELECT $1, $2, $3, $4, $5\n\t\t\tFROM oauth_client_authorizations auths\n\t\t\tJOIN users ON users.id = auths.user_id\n\t\t\tWHERE auths.id = $2 AND NOT users.account_locked\n RETURNING created, expires\n ", "describe": { "columns": [ { @@ -28,5 +28,5 @@ false ] }, - "hash": "6b881555e610ddc6796cdcbfd2de26e68b10522d0f1df3f006d58f6b72be9911" + "hash": "a120bab0d2d0f721bade7fdb7a4854aed5d31564ec8896635f81ded9b02153d7" } diff --git a/apps/labrinth/.sqlx/query-77d39c54d5ca1622b53f029cb4c13edaed963e65d9e8fb9c58c116bba24fe2ac.json b/apps/labrinth/.sqlx/query-a464c4a96fc8690891532af23faaa92d5f36efaf4aac771cbd2575b10613dd87.json similarity index 57% rename from apps/labrinth/.sqlx/query-77d39c54d5ca1622b53f029cb4c13edaed963e65d9e8fb9c58c116bba24fe2ac.json rename to apps/labrinth/.sqlx/query-a464c4a96fc8690891532af23faaa92d5f36efaf4aac771cbd2575b10613dd87.json index acc7285b88..1fe976d774 100644 --- a/apps/labrinth/.sqlx/query-77d39c54d5ca1622b53f029cb4c13edaed963e65d9e8fb9c58c116bba24fe2ac.json +++ b/apps/labrinth/.sqlx/query-a464c4a96fc8690891532af23faaa92d5f36efaf4aac771cbd2575b10613dd87.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET paypal_country = $1, paypal_email = $2, paypal_id = $3\n WHERE id = $4\n ", + "query": "\n UPDATE users\n SET paypal_country = $1, paypal_email = $2, paypal_id = $3\n\t\t\t\tWHERE id = $4 AND NOT account_locked\n ", "describe": { "columns": [], "parameters": { @@ -13,5 +13,5 @@ }, "nullable": [] }, - "hash": "77d39c54d5ca1622b53f029cb4c13edaed963e65d9e8fb9c58c116bba24fe2ac" + "hash": "a464c4a96fc8690891532af23faaa92d5f36efaf4aac771cbd2575b10613dd87" } diff --git a/apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json b/apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json deleted file mode 100644 index 4adb958c1f..0000000000 --- a/apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n users.id,\n users_redeemals.status AS \"status: Option\"\n FROM\n users\n LEFT JOIN\n users_redeemals ON users_redeemals.user_id = users.id\n AND users_redeemals.offer = $2\n WHERE\n users.username = $1\n ORDER BY\n users_redeemals.redeemed DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "status: Option", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652" -} diff --git a/apps/labrinth/.sqlx/query-bd98516db2f812b2655b57801875f51617eaa22cb287b266c6bca7b54bf8cfce.json b/apps/labrinth/.sqlx/query-bd98516db2f812b2655b57801875f51617eaa22cb287b266c6bca7b54bf8cfce.json new file mode 100644 index 0000000000..e7b43d24cc --- /dev/null +++ b/apps/labrinth/.sqlx/query-bd98516db2f812b2655b57801875f51617eaa22cb287b266c6bca7b54bf8cfce.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n users.id,\n\t\t\t\tusers.account_locked,\n users_redeemals.status AS \"status: Option\"\n FROM\n users\n LEFT JOIN\n users_redeemals ON users_redeemals.user_id = users.id\n AND users_redeemals.offer = $2\n WHERE\n users.username = $1\n ORDER BY\n users_redeemals.redeemed DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "account_locked", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "status: Option", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "bd98516db2f812b2655b57801875f51617eaa22cb287b266c6bca7b54bf8cfce" +} diff --git a/apps/labrinth/.sqlx/query-a335ff4338fa4b3c1aaa9f62c61aad3c3e8bef0e68035a024995c62cfe69c568.json b/apps/labrinth/.sqlx/query-c33cd0ff271a396af728cae2df4900bcbf16cd76005e9d029d2153a5504016cc.json similarity index 66% rename from apps/labrinth/.sqlx/query-a335ff4338fa4b3c1aaa9f62c61aad3c3e8bef0e68035a024995c62cfe69c568.json rename to apps/labrinth/.sqlx/query-c33cd0ff271a396af728cae2df4900bcbf16cd76005e9d029d2153a5504016cc.json index bfc425a3b4..f40ebd372a 100644 --- a/apps/labrinth/.sqlx/query-a335ff4338fa4b3c1aaa9f62c61aad3c3e8bef0e68035a024995c62cfe69c568.json +++ b/apps/labrinth/.sqlx/query-c33cd0ff271a396af728cae2df4900bcbf16cd76005e9d029d2153a5504016cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22,\n $23\n )\n ", + "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n\t\t\t\teligibility_verified_at, account_locked\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22,\n\t\t\t\t$23, $24\n )\n ", "describe": { "columns": [], "parameters": { @@ -27,10 +27,11 @@ "Text", "Bool", "Bool", - "Timestamptz" + "Timestamptz", + "Bool" ] }, "nullable": [] }, - "hash": "a335ff4338fa4b3c1aaa9f62c61aad3c3e8bef0e68035a024995c62cfe69c568" + "hash": "c33cd0ff271a396af728cae2df4900bcbf16cd76005e9d029d2153a5504016cc" } diff --git a/apps/labrinth/.sqlx/query-05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b.json b/apps/labrinth/.sqlx/query-de703148611b2cdc3b5743046098892405dd4df00fa9548c643ea8e33e4f06df.json similarity index 63% rename from apps/labrinth/.sqlx/query-05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b.json rename to apps/labrinth/.sqlx/query-de703148611b2cdc3b5743046098892405dd4df00fa9548c643ea8e33e4f06df.json index 106e54f7af..3abc7c8136 100644 --- a/apps/labrinth/.sqlx/query-05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b.json +++ b/apps/labrinth/.sqlx/query-de703148611b2cdc3b5743046098892405dd4df00fa9548c643ea8e33e4f06df.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, user_id, name, credential_id,\n passkey AS \"passkey: sqlx::types::Json\",\n last_used, created_at\n FROM user_passkeys\n WHERE credential_id = $1\n ", + "query": "\n\t\t\tSELECT user_passkeys.id, user_id, name, credential_id,\n passkey AS \"passkey: sqlx::types::Json\",\n\t\t\t\t last_used, created_at,\n\t\t\t\t users.account_locked\n FROM user_passkeys\n\t\t\tJOIN users ON users.id = user_passkeys.user_id\n WHERE credential_id = $1\n ", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "ordinal": 6, "name": "created_at", "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "account_locked", + "type_info": "Bool" } ], "parameters": { @@ -51,8 +56,9 @@ false, false, true, + false, false ] }, - "hash": "05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b" + "hash": "de703148611b2cdc3b5743046098892405dd4df00fa9548c643ea8e33e4f06df" } diff --git a/apps/labrinth/migrations/20260904120000_account_locked.sql b/apps/labrinth/migrations/20260904120000_account_locked.sql new file mode 100644 index 0000000000..0cc3a7d840 --- /dev/null +++ b/apps/labrinth/migrations/20260904120000_account_locked.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN account_locked boolean NOT NULL DEFAULT FALSE; diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index c58a032f13..03f4f7dfad 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -9,8 +9,8 @@ pub use checks::{ }; use serde::{Deserialize, Serialize}; pub use validate::{ - check_is_moderator_from_headers, get_user_from_bearer_token, - get_user_from_headers, + check_account_unlocked, check_is_moderator_from_headers, + get_user_from_bearer_token, get_user_from_headers, }; use crate::file_hosting::FileHostingError; @@ -39,6 +39,8 @@ pub enum AuthenticationError { Mail(#[from] crate::queue::email::MailError), #[error("Invalid Authentication Credentials")] InvalidCredentials, + #[error("account is locked")] + AccountLocked, #[error("Authentication method was not valid")] InvalidAuthMethod, #[error("GitHub Token from incorrect Client ID")] @@ -74,6 +76,7 @@ impl actix_web::ResponseError for AuthenticationError { StatusCode::INTERNAL_SERVER_ERROR } AuthenticationError::InvalidCredentials => StatusCode::UNAUTHORIZED, + AuthenticationError::AccountLocked => StatusCode::FORBIDDEN, AuthenticationError::Decoding(..) => StatusCode::BAD_REQUEST, AuthenticationError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR, AuthenticationError::InvalidAuthMethod => StatusCode::UNAUTHORIZED, @@ -109,6 +112,7 @@ impl AuthenticationError { AuthenticationError::SerDe(..) => "invalid_input", AuthenticationError::Reqwest(..) => "network_error", AuthenticationError::InvalidCredentials => "invalid_credentials", + AuthenticationError::AccountLocked => "account_locked", AuthenticationError::Decoding(..) => "decoding_error", AuthenticationError::Mail(..) => "mail_error", AuthenticationError::InvalidAuthMethod => "invalid_auth_method", diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs index 09691afe84..15673c5fd6 100644 --- a/apps/labrinth/src/auth/oauth/errors.rs +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -58,6 +58,9 @@ impl OAuthError { impl actix_web::ResponseError for OAuthError { fn status_code(&self) -> StatusCode { match *self.error_type { + OAuthErrorType::AuthenticationError( + AuthenticationError::AccountLocked, + ) if self.valid_redirect_uri.is_none() => StatusCode::FORBIDDEN, OAuthErrorType::AuthenticationError(_) | OAuthErrorType::FailedScopeParse(_) | OAuthErrorType::ScopesTooBroad @@ -174,6 +177,9 @@ impl OAuthErrorType { match self { Self::RedirectUriNotConfigured(_) | Self::ClientMissingRedirectURI { client_id: _ } => "invalid_uri", + Self::AuthenticationError(AuthenticationError::AccountLocked) => { + "access_denied" + } Self::AuthenticationError(_) | Self::InvalidAcceptFlowId => { "server_error" } diff --git a/apps/labrinth/src/auth/oauth/mod.rs b/apps/labrinth/src/auth/oauth/mod.rs index 083d0fabd6..55595c997b 100644 --- a/apps/labrinth/src/auth/oauth/mod.rs +++ b/apps/labrinth/src/auth/oauth/mod.rs @@ -313,7 +313,10 @@ pub async fn request_token( user_id, } .insert(&mut transaction) - .await?; + .await? + .ok_or_else(|| { + OAuthError::error(OAuthErrorType::InvalidAuthCode) + })?; transaction.commit().await?; diff --git a/apps/labrinth/src/auth/templates/mod.rs b/apps/labrinth/src/auth/templates/mod.rs index f4e3458784..9ac1b801e1 100644 --- a/apps/labrinth/src/auth/templates/mod.rs +++ b/apps/labrinth/src/auth/templates/mod.rs @@ -40,7 +40,7 @@ impl Display for ErrorPage { impl ErrorPage { pub fn render(&self) -> HttpResponse { - HttpResponse::Ok() + HttpResponse::build(self.code) .append_header(("Content-Type", "text/html; charset=utf-8")) .body(self.to_string()) } diff --git a/apps/labrinth/src/auth/validate.rs b/apps/labrinth/src/auth/validate.rs index 46160e5048..bf14b2bf14 100644 --- a/apps/labrinth/src/auth/validate.rs +++ b/apps/labrinth/src/auth/validate.rs @@ -11,6 +11,16 @@ use actix_web::http::header::{AUTHORIZATION, HeaderValue}; use chrono::Utc; use xredis::RedisPool; +pub fn check_account_unlocked( + account_locked: bool, +) -> Result<(), AuthenticationError> { + match account_locked { + true => Err(AuthenticationError::AccountLocked), + false => Ok(()), + } +} + +/// Allows anonymous or invalid credentials, but rejects locked accounts. pub async fn get_maybe_user_from_headers<'a, E>( req: &HttpRequest, executor: E, @@ -25,8 +35,34 @@ where return Ok(None); } - // Fetch DB user record and minos user from headers - let Some((scopes, db_user)) = get_user_record_from_bearer_token( + match get_user_from_headers( + req, + executor, + redis, + session_queue, + required_scopes, + ) + .await + { + Ok(user) => Ok(Some(user)), + Err(AuthenticationError::AccountLocked) => { + Err(AuthenticationError::AccountLocked) + } + Err(_) => Ok(None), + } +} + +pub async fn get_full_user_from_headers<'a, E>( + req: &HttpRequest, + executor: E, + redis: &RedisPool, + session_queue: &AuthQueue, + required_scopes: Scopes, +) -> Result<(Scopes, DBUser), AuthenticationError> +where + E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + let (scopes, db_user) = get_user_record_from_bearer_token( req, None, executor, @@ -35,18 +71,16 @@ where false, ) .await? - else { - return Ok(None); - }; - + .ok_or(AuthenticationError::InvalidCredentials)?; if !scopes.contains(required_scopes) { - return Ok(None); + return Err(AuthenticationError::InvalidCredentials); } - - Ok(Some((scopes, User::from_full(db_user)))) + Ok((scopes, db_user)) } -pub async fn get_full_user_from_headers<'a, E>( +/// Authenticates without rejecting locked accounts. The caller must check +/// `account_locked` before exposing private data or allowing other actions. +pub async fn get_full_user_from_headers_allow_locked<'a, E>( req: &HttpRequest, executor: E, redis: &RedisPool, @@ -56,7 +90,7 @@ pub async fn get_full_user_from_headers<'a, E>( where E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, { - let (scopes, db_user) = get_user_record_from_bearer_token( + let (scopes, db_user) = get_user_record_from_bearer_token_allow_locked( req, None, executor, @@ -122,14 +156,40 @@ where Ok((scopes, User::from_full(db_user))) } -pub async fn get_user_record_from_bearer_token<'a, 'b, E>( +pub async fn get_user_record_from_bearer_token<'a, E>( + req: &HttpRequest, + token: Option<&str>, + executor: E, + redis: &RedisPool, + session_queue: &AuthQueue, + allow_expired: bool, +) -> Result, AuthenticationError> +where + E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + let user = get_user_record_from_bearer_token_allow_locked( + req, + token, + executor, + redis, + session_queue, + allow_expired, + ) + .await?; + if let Some((_, user)) = &user { + check_account_unlocked(user.account_locked)?; + } + Ok(user) +} + +async fn get_user_record_from_bearer_token_allow_locked<'a, E>( req: &HttpRequest, token: Option<&str>, executor: E, redis: &RedisPool, session_queue: &AuthQueue, allow_expired: bool, -) -> Result, AuthenticationError> +) -> Result, AuthenticationError> where E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy, { diff --git a/apps/labrinth/src/database/models/oauth_token_item.rs b/apps/labrinth/src/database/models/oauth_token_item.rs index b4191bff09..7a1777e6e7 100644 --- a/apps/labrinth/src/database/models/oauth_token_item.rs +++ b/apps/labrinth/src/database/models/oauth_token_item.rs @@ -66,15 +66,16 @@ impl DBOAuthAccessToken { pub async fn insert( &self, exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, - ) -> Result { + ) -> Result, DatabaseError> { let r = sqlx::query!( " INSERT INTO oauth_access_tokens ( id, authorization_id, token_hash, scopes, last_used ) - VALUES ( - $1, $2, $3, $4, $5 - ) + SELECT $1, $2, $3, $4, $5 + FROM oauth_client_authorizations auths + JOIN users ON users.id = auths.user_id + WHERE auths.id = $2 AND NOT users.account_locked RETURNING created, expires ", self.id.0, @@ -83,13 +84,10 @@ impl DBOAuthAccessToken { self.scopes.to_postgres(), Option::>::None ) - .fetch_one(exec) + .fetch_optional(exec) .await?; - let (created, expires) = (r.created, r.expires); - let time_until_expiration = expires - created; - - Ok(time_until_expiration) + Ok(r.map(|r| r.expires - r.created)) } pub fn hash_token(token: &str) -> String { diff --git a/apps/labrinth/src/database/models/passkey_item.rs b/apps/labrinth/src/database/models/passkey_item.rs index 063e0724f4..bb0c1159e7 100644 --- a/apps/labrinth/src/database/models/passkey_item.rs +++ b/apps/labrinth/src/database/models/passkey_item.rs @@ -49,30 +49,37 @@ impl DBPasskey { pub async fn get_by_credential_id<'a, E>( credential_id: &[u8], exec: E, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { let row = sqlx::query!( r#" - SELECT id, user_id, name, credential_id, + SELECT user_passkeys.id, user_id, name, credential_id, passkey AS "passkey: sqlx::types::Json", - last_used, created_at + last_used, created_at, + users.account_locked FROM user_passkeys + JOIN users ON users.id = user_passkeys.user_id WHERE credential_id = $1 "#, credential_id, ) .fetch_optional(exec) .await? - .map(|x| DBPasskey { - id: DBPasskeyId(x.id), - user_id: DBUserId(x.user_id), - name: x.name, - credential_id: x.credential_id, - passkey: x.passkey.0, - created_at: x.created_at, - last_used: x.last_used, + .map(|x| { + ( + DBPasskey { + id: DBPasskeyId(x.id), + user_id: DBUserId(x.user_id), + name: x.name, + credential_id: x.credential_id, + passkey: x.passkey.0, + created_at: x.created_at, + last_used: x.last_used, + }, + x.account_locked, + ) }); Ok(row) diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index cb88f48a72..a4161bba92 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -20,7 +20,7 @@ use std::fmt::{Debug, Display}; use std::hash::Hash; use xredis::RedisPool; -const USERS_NAMESPACE: &str = "users:v4"; +const USERS_NAMESPACE: &str = "users:v5"; const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v4"; const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v4"; @@ -52,6 +52,7 @@ pub struct DBUser { pub bio: Option, pub created: DateTime, pub role: String, + pub account_locked: bool, pub badges: Badges, #[serde(default)] pub campaign_pride_26: Option, @@ -90,14 +91,14 @@ impl DBUser { github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, email_verified, password, paypal_id, paypal_country, paypal_email, venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter, - eligibility_verified_at + eligibility_verified_at, account_locked ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, - $23 + $23, $24 ) ", self.id as DBUserId, @@ -123,6 +124,7 @@ impl DBUser { self.allow_friend_requests, self.is_subscribed_to_newsletter, self.eligibility_verified_at, + self.account_locked, ) .execute(&mut *transaction) .await?; @@ -202,10 +204,10 @@ impl DBUser { .collect::>(); let users = sqlx::query!( - " + r#" SELECT id, email, avatar_url, raw_avatar_url, username, bio, - created, role, badges, + created, role, badges, account_locked, ( SELECT MAX(campaign_donations.donated_at) FROM campaign_donations @@ -222,7 +224,7 @@ impl DBUser { eligibility_verified_at FROM users WHERE id = ANY($1) OR LOWER(username) = ANY($2) - ", + "#, &user_ids, &slugs, ) @@ -244,6 +246,7 @@ impl DBUser { bio: u.bio, created: u.created, role: u.role, + account_locked: u.account_locked, badges: Badges::from_bits(u.badges as u64).unwrap_or_default(), campaign_pride_26: u .campaign_pride_26_last_donated_at diff --git a/apps/labrinth/src/database/models/users_redeemals.rs b/apps/labrinth/src/database/models/users_redeemals.rs index b72654d173..fa3f58ed19 100644 --- a/apps/labrinth/src/database/models/users_redeemals.rs +++ b/apps/labrinth/src/database/models/users_redeemals.rs @@ -244,6 +244,7 @@ impl UserRedeemal { #[derive(Debug)] pub struct RedeemalLookupFields { pub user_id: DBUserId, + pub account_locked: bool, pub redeemal_status: Option, } @@ -267,6 +268,7 @@ impl RedeemalLookupFields { r#" SELECT users.id, + users.account_locked, users_redeemals.status AS "status: Option" FROM users @@ -290,6 +292,7 @@ impl RedeemalLookupFields { Ok(maybe_row.map(|row| RedeemalLookupFields { user_id: DBUserId(row.id), + account_locked: row.account_locked, redeemal_status: row .status .as_deref() diff --git a/apps/labrinth/src/models/v2/user.rs b/apps/labrinth/src/models/v2/user.rs index 35622abf8d..6ad98a2291 100644 --- a/apps/labrinth/src/models/v2/user.rs +++ b/apps/labrinth/src/models/v2/user.rs @@ -15,6 +15,8 @@ pub struct LegacyUser { pub bio: Option, pub created: DateTime, pub role: Role, + #[serde(skip_serializing_if = "Option::is_none")] + pub account_locked: Option, pub badges: Badges, pub auth_providers: Option>, // this was changed in v3, but not changes ones we want to keep out of v2 @@ -39,6 +41,7 @@ impl From for LegacyUser { bio: data.bio, created: data.created, role: data.role, + account_locked: data.account_locked, badges: data.badges, payout_data: data.payout_data, auth_providers: data.auth_providers, diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs index fe5de4c18d..3f167877c0 100644 --- a/apps/labrinth/src/models/v3/users.rs +++ b/apps/labrinth/src/models/v3/users.rs @@ -55,6 +55,8 @@ pub struct User { pub bio: Option, pub created: DateTime, pub role: Role, + #[serde(skip_serializing_if = "Option::is_none")] + pub account_locked: Option, pub badges: Badges, pub campaigns: UserCampaigns, @@ -113,6 +115,7 @@ impl From for User { bio: data.bio, created: data.created, role: Role::from_string(&data.role), + account_locked: None, badges: data.badges, campaigns: UserCampaigns { pride_26: data.campaign_pride_26, @@ -177,6 +180,7 @@ impl User { bio: db_user.bio, created: db_user.created, role: Role::from_string(&db_user.role), + account_locked: Some(db_user.account_locked), badges: db_user.badges, campaigns: UserCampaigns { pride_26: db_user.campaign_pride_26, diff --git a/apps/labrinth/src/routes/analytics.rs b/apps/labrinth/src/routes/analytics.rs index 7330f80d59..da289a65f5 100644 --- a/apps/labrinth/src/routes/analytics.rs +++ b/apps/labrinth/src/routes/analytics.rs @@ -1,4 +1,5 @@ use crate::auth::get_user_from_headers; +use crate::auth::validate::get_maybe_user_from_headers; use crate::database::PgPool; use crate::database::models::DBProject; use crate::env::ENV; @@ -74,7 +75,7 @@ pub async fn page_view_ingest( pool: web::Data, redis: web::Data, ) -> Result { - let user = get_user_from_headers( + let user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -82,7 +83,7 @@ pub async fn page_view_ingest( Scopes::empty(), ) .await - .ok(); + .wrap_auth_err("authenticating API request")?; let conn_info = req.connection_info().peer_addr().map(|x| x.to_string()); let url = Url::parse(&url_input.url) @@ -290,7 +291,7 @@ pub async fn minecraft_server_play_ingest( redis: web::Data, http: web::Data, ) -> Result<(), ApiError> { - let user = get_user_from_headers( + let user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -298,8 +299,8 @@ pub async fn minecraft_server_play_ingest( Scopes::empty(), ) .await - .map(|(_, user)| user) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|(_, user)| user); let project_id = play_input.project_id; diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs index 0fa0196156..7e8c1978e2 100644 --- a/apps/labrinth/src/routes/internal/admin.rs +++ b/apps/labrinth/src/routes/internal/admin.rs @@ -1,3 +1,4 @@ +use crate::auth::AuthenticationError; use crate::auth::validate::get_user_record_from_bearer_token; use crate::database::PgPool; use crate::models::analytics::{Download, DownloadReason}; @@ -161,7 +162,7 @@ pub async fn count_download( .find(|x| x.0.to_lowercase() == "authorization") .map(|x| &**x.1); - let user = get_user_record_from_bearer_token( + let user = match get_user_record_from_bearer_token( &req, token, &**pool, @@ -170,8 +171,14 @@ pub async fn count_download( false, ) .await - .ok() - .flatten(); + { + Ok(user) => user, + Err(AuthenticationError::AccountLocked) => { + return Err(AuthenticationError::AccountLocked) + .wrap_auth_err("authenticating API request"); + } + Err(_) => None, + }; let project_id: crate::database::models::ids::DBProjectId = download_body.project_id.into(); diff --git a/apps/labrinth/src/routes/internal/affiliate.rs b/apps/labrinth/src/routes/internal/affiliate.rs index 8716dffba1..413a86c461 100644 --- a/apps/labrinth/src/routes/internal/affiliate.rs +++ b/apps/labrinth/src/routes/internal/affiliate.rs @@ -1,3 +1,4 @@ +use crate::auth::validate::get_maybe_user_from_headers; use std::{collections::HashMap, net::Ipv4Addr, sync::Arc}; use xredis::RedisPool; @@ -53,7 +54,7 @@ pub async fn ingest_click( session_queue: web::Data, analytics_queue: web::Data>, ) -> Result<(), ApiError> { - let user = get_user_from_headers( + let user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -61,8 +62,8 @@ pub async fn ingest_click( Scopes::empty(), ) .await - .map(|(_, user)| user) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|(_, user)| user); let conn_info = req.connection_info().peer_addr().map(|x| x.to_string()); let url = ingest_click.url; diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index fd22aaf6d4..f77761b3bc 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -1,7 +1,10 @@ use crate::auth::validate::{ get_full_user_from_headers, get_user_record_from_bearer_token, }; -use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers}; +use crate::auth::{ + AuthProvider, AuthenticationError, check_account_unlocked, + get_user_from_headers, +}; use crate::database::PgPool; use crate::database::PgTransaction; use crate::database::models::flow_item::DBFlow; @@ -252,6 +255,7 @@ impl TempUser { bio: self.bio, created: Utc::now(), role: Role::Developer.to_string(), + account_locked: false, badges: Badges::default(), campaign_pride_26: None, allow_friend_requests: true, @@ -1105,9 +1109,7 @@ pub async fn init( &session_queue, false, ) - .await - .ok() - .flatten() + .await? .map(|(_scopes, user)| user.id) } else { None @@ -1290,11 +1292,11 @@ pub async fn auth_callback( "attempting to link a PayPal account without being logged in", )?; - sqlx::query!( + let updated = sqlx::query!( " UPDATE users SET paypal_country = $1, paypal_email = $2, paypal_id = $3 - WHERE id = $4 + WHERE id = $4 AND NOT account_locked ", oauth_user.country, oauth_user.email, @@ -1305,6 +1307,10 @@ pub async fn auth_callback( .await .wrap_err("failed to update user PayPal info")?; + if updated.rows_affected() == 0 { + return Err(AuthenticationError::AccountLocked); + } + transaction .commit() .await @@ -1326,23 +1332,22 @@ pub async fn auth_callback( return Err(AuthenticationError::ProviderAlreadyLinked); } + let user = DBUser::get_id(id, &**client, &redis) + .await? + .ok_or(AuthenticationError::InvalidCredentials)?; + check_account_unlocked(user.account_locked)?; + provider .update_user_id(id, Some(&oauth_user.id), &mut transaction) .await?; - let user = - crate::database::models::DBUser::get_id(id, &**client, &redis) - .await?; - - if let Some(user) = user { - NotificationBuilder { - body: NotificationBody::AuthProviderAdded { - provider: provider.as_str().to_string(), - }, - } - .insert(user.id, &mut transaction, &redis) - .await?; + NotificationBuilder { + body: NotificationBody::AuthProviderAdded { + provider: provider.as_str().to_string(), + }, } + .insert(user.id, &mut transaction, &redis) + .await?; transaction.commit().await?; crate::database::models::DBUser::clear_caches( @@ -1361,6 +1366,8 @@ pub async fn auth_callback( .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + check_account_unlocked(user.account_locked)?; + if user.totp_secret.is_some() { let flow = DBFlow::Login2FA { user_id: user.id } .insert(Duration::minutes(30), &redis) @@ -2096,6 +2103,7 @@ impl ReadyAccountRegisterFlow { bio: None, created: Utc::now(), role: Role::Developer.to_string(), + account_locked: false, badges: Badges::default(), campaign_pride_26: None, allow_friend_requests: true, @@ -2312,6 +2320,9 @@ pub async fn login_password( .map_err(|_| AuthenticationError::InvalidCredentials) .wrap_auth_err("authenticating API request")?; + check_account_unlocked(user.account_locked) + .wrap_auth_err("checking account lock")?; + if user.totp_secret.is_some() { let flow = DBFlow::Login2FA { user_id: user.id } .insert(Duration::minutes(30), &redis) @@ -2453,6 +2464,9 @@ pub async fn login_2fa( .ok_or_else(|| AuthenticationError::InvalidCredentials) .wrap_auth_err("fetching user from database")?; + check_account_unlocked(user.account_locked) + .wrap_auth_err("checking account lock")?; + let mut transaction = pool .begin() .await @@ -2904,9 +2918,13 @@ pub async fn reset_password_begin( if let Some(DBUser { id: user_id, email: user_email, + account_locked, .. }) = user { + check_account_unlocked(account_locked) + .wrap_auth_err("checking account lock")?; + let flow = DBFlow::ForgotPassword { user_id } .insert(Duration::hours(24), &redis) .await @@ -3030,6 +3048,9 @@ pub async fn change_password( user }; + check_account_unlocked(user.account_locked) + .wrap_auth_err("checking account lock")?; + let mut transaction = pool .begin() .await @@ -3401,6 +3422,9 @@ pub async fn verify_email( .ok_or_else(|| AuthenticationError::InvalidCredentials) .wrap_auth_err("fetching user from database")?; + check_account_unlocked(user.account_locked) + .wrap_auth_err("checking account lock")?; + if user.email != Some(confirm_email) { return Err(ApiError::Request(eyre::eyre!( "E-mail does not match verify email. Try re-requesting the verification link.", @@ -3841,12 +3865,15 @@ pub async fn authenticate_passkey_finish( if let Some(DBFlow::AuthenticatePasskey { state }) = flow { let credential_id = response.credential.get_credential_id(); - let db_passkey = + let (db_passkey, account_locked) = DBPasskey::get_by_credential_id(credential_id, &**pool) .await .wrap_internal_err("failed to fetch passkey")? .wrap_request_err_with(|| "passkey not found")?; + check_account_unlocked(account_locked) + .wrap_auth_err("checking account lock")?; + let mut transaction = pool .begin() .await diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs index c443e19e75..c8c188886d 100644 --- a/apps/labrinth/src/routes/internal/medal.rs +++ b/apps/labrinth/src/routes/internal/medal.rs @@ -1,3 +1,4 @@ +use crate::auth::check_account_unlocked; use crate::database::PgPool; use crate::util::error::Context as _; use actix_web::{HttpResponse, post, web}; @@ -51,10 +52,14 @@ pub async fn verify( match maybe_fields { None => Err(ApiError::NotFound(eyre::eyre!("resource not found"))), - Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse { - user_id: fields.user_id.into(), - redeemed: fields.redeemal_status.is_some(), - })), + Some(fields) => { + check_account_unlocked(fields.account_locked) + .wrap_auth_err("checking account lock")?; + Ok(HttpResponse::Ok().json(VerifyResponse { + user_id: fields.user_id.into(), + redeemed: fields.redeemal_status.is_some(), + })) + } } } @@ -92,6 +97,8 @@ pub async fn redeem( return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } Some(fields) => { + check_account_unlocked(fields.account_locked) + .wrap_auth_err("checking account lock")?; if fields.redeemal_status.is_some() { return Err(ApiError::Conflict(eyre::eyre!( "User already redeemed this offer", diff --git a/apps/labrinth/src/routes/internal/privacy.rs b/apps/labrinth/src/routes/internal/privacy.rs index 27a2cbd984..467986c8cc 100644 --- a/apps/labrinth/src/routes/internal/privacy.rs +++ b/apps/labrinth/src/routes/internal/privacy.rs @@ -1,3 +1,4 @@ +use crate::auth::check_account_unlocked; use crate::database::PgPool; use crate::database::models::blocked_user_item::DBBlockedUser; use crate::database::models::friend_item::DBFriend; @@ -49,7 +50,10 @@ pub async fn invite_privacy_status( ) .wrap_internal_err("resolving user ids")?; - let user_id = user.wrap_not_found_err("user not found")?.id; + let user = user.wrap_not_found_err("user not found")?; + check_account_unlocked(user.account_locked) + .wrap_auth_err("checking inviter account lock")?; + let user_id = user.id; let target_id = target.wrap_not_found_err("target not found")?.id; let blocked = DBBlockedUser::is_blocked(target_id, user_id, &**pool) diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index 369eaf6d1f..cf7733225b 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -1,4 +1,6 @@ use crate::auth::checks::{is_visible_project, is_visible_version}; +use crate::auth::validate::get_maybe_user_from_headers; +use crate::database; use crate::database::PgPool; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::models::loader_fields::Loader; @@ -13,7 +15,6 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::ApiContext as _; use crate::util::error::Context; -use crate::{auth::get_user_from_headers, database}; use actix_web::{HttpRequest, HttpResponse, get, route, web}; use quick_xml::escape::escape; use std::collections::HashSet; @@ -94,7 +95,7 @@ pub async fn maven_metadata( return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -102,8 +103,8 @@ pub async fn maven_metadata( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if !is_visible_project(&project.inner, &user_option, &pool, false) .await @@ -332,7 +333,7 @@ pub async fn version_file( return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -340,8 +341,8 @@ pub async fn version_file( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if !is_visible_project(&project.inner, &user_option, &pool, false) .await @@ -419,7 +420,7 @@ pub async fn version_file_sha1( return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -427,8 +428,8 @@ pub async fn version_file_sha1( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if !is_visible_project(&project.inner, &user_option, &pool, false) .await @@ -485,7 +486,7 @@ pub async fn version_file_sha512( return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -493,8 +494,8 @@ pub async fn version_file_sha512( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if !is_visible_project(&project.inner, &user_option, &pool, false) .await diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 3713066342..7db046c48b 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -1,3 +1,4 @@ +use crate::auth::AuthenticationError; use crate::env::ENV; use crate::util::cors::default_cors; use actix_cors::Cors; @@ -275,7 +276,14 @@ impl actix_web::ResponseError for ApiError { match self { Self::Internal(..) => StatusCode::INTERNAL_SERVER_ERROR, Self::Request(..) => StatusCode::BAD_REQUEST, - Self::Auth(..) => StatusCode::UNAUTHORIZED, + Self::Auth(report) => { + match report.downcast_ref::() { + Some(AuthenticationError::AccountLocked) => { + StatusCode::FORBIDDEN + } + _ => StatusCode::UNAUTHORIZED, + } + } Self::NotFound(..) => StatusCode::NOT_FOUND, Self::Conflict(..) => StatusCode::CONFLICT, Self::FailedDependency(..) => StatusCode::FAILED_DEPENDENCY, @@ -294,6 +302,26 @@ impl actix_web::ResponseError for ApiError { mod tests { use super::ApiError; + #[test] + fn account_locked_preserves_forbidden_status_through_auth_context() { + use crate::{ + auth::{AuthenticationError, templates::ErrorPage}, + util::error::Context, + }; + use actix_web::{ResponseError, http::StatusCode}; + + let direct = AuthenticationError::AccountLocked; + assert_eq!(direct.status_code(), StatusCode::FORBIDDEN); + let wrapped = Err::<(), _>(direct) + .wrap_auth_err("checking account lock") + .unwrap_err() + .wrap_err("authenticating API request"); + assert_eq!(wrapped.status_code(), StatusCode::FORBIDDEN); + assert_eq!(wrapped.as_api_error().error, "auth_error"); + let page = ErrorPage::from(AuthenticationError::AccountLocked); + assert_eq!(page.error_response().status(), StatusCode::FORBIDDEN); + } + #[test] fn api_error_serializes_source_chain_as_details() { let error = ApiError::Request( diff --git a/apps/labrinth/src/routes/updates.rs b/apps/labrinth/src/routes/updates.rs index 95db80199d..cebdc0c3d9 100644 --- a/apps/labrinth/src/routes/updates.rs +++ b/apps/labrinth/src/routes/updates.rs @@ -1,3 +1,4 @@ +use crate::auth::validate::get_maybe_user_from_headers; use crate::util::error::ApiContext as _; use crate::util::error::Context as _; use std::cmp::Reverse; @@ -9,7 +10,6 @@ use actix_web::{HttpRequest, HttpResponse, get, web}; use serde::{Deserialize, Serialize}; use crate::auth::checks::{filter_visible_versions, is_visible_project}; -use crate::auth::get_user_from_headers; use crate::database; use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::models::pats::Scopes; @@ -61,7 +61,7 @@ pub async fn forge_updates( .wrap_api_err("fetching project from database")? .wrap_request_err_with(|| ERROR.to_string())?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -69,8 +69,8 @@ pub async fn forge_updates( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if !is_visible_project(&project.inner, &user_option, &pool, false) .await diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs index 860159d638..c75cfc45db 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -223,6 +223,7 @@ pub struct EditUser { #[validate(length(max = 160))] pub bio: Option>, pub role: Option, + pub account_locked: Option, pub badges: Option, pub allow_friend_requests: Option, } @@ -268,6 +269,7 @@ pub async fn user_edit( username: new_user.username, bio: new_user.bio, role: new_user.role, + account_locked: new_user.account_locked, badges: new_user.badges, venmo_handle: None, allow_friend_requests: new_user.allow_friend_requests, diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index b5e5d266a8..e740222a7b 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -1,4 +1,5 @@ use crate::auth::checks::is_visible_collection; +use crate::auth::validate::get_maybe_user_from_headers; use crate::auth::{ filter_visible_collections, get_user_from_headers, require_verified_email, }; @@ -173,7 +174,7 @@ pub async fn collections_get( .await .wrap_internal_err("fetching collections from database")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -181,8 +182,8 @@ pub async fn collections_get( Scopes::COLLECTION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let collections = filter_visible_collections(collections_data, &user_option, false) @@ -210,7 +211,7 @@ pub async fn collection_get( database::models::DBCollection::get(id, &**pool, &redis) .await .wrap_internal_err("fetching collection from database")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -218,8 +219,8 @@ pub async fn collection_get( Scopes::COLLECTION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if let Some(data) = collection_data && is_visible_collection(&data, &user_option, false) diff --git a/apps/labrinth/src/routes/v3/content/mod.rs b/apps/labrinth/src/routes/v3/content/mod.rs index 51379b7f1b..33d8827f6e 100644 --- a/apps/labrinth/src/routes/v3/content/mod.rs +++ b/apps/labrinth/src/routes/v3/content/mod.rs @@ -2,7 +2,7 @@ use super::ApiError; use crate::auth::checks::{ filter_visible_versions, is_visible_project, is_visible_version, }; -use crate::auth::get_user_from_headers; +use crate::auth::validate::get_maybe_user_from_headers; use crate::database::models::ids::DBVersionId; use crate::database::models::version_item::VersionQueryResult; use crate::database::models::{DBProject, DBVersion}; @@ -12,6 +12,7 @@ use crate::models::projects::{DependencyType, Version}; use crate::models::users::User; use crate::queue::session::AuthQueue; use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use actix_web::{HttpRequest, post, web}; use ariadne::ids::base62_impl::parse_base62; use async_trait::async_trait; @@ -48,7 +49,7 @@ pub async fn resolve_content( redis: web::Data, session_queue: web::Data, ) -> Result, ApiError> { - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -56,8 +57,8 @@ pub async fn resolve_content( Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let cache_public_result = user_option.is_none(); let mut provider = LabrinthContentProvider { pool: pool.get_ref(), diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 9923e7b36e..1d4c2e485e 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -1,3 +1,4 @@ +use crate::auth::validate::get_maybe_user_from_headers; use actix_web::{HttpRequest, get, patch, web}; use chrono::Utc; use eyre::eyre; @@ -53,7 +54,7 @@ pub async fn get_project_disclosures( .wrap_internal_err("failed to fetch project")? .wrap_not_found_err("resource not found")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -61,8 +62,8 @@ pub async fn get_project_disclosures( Scopes::PROJECT_READ, ) .await - .map(|(_, user)| user) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|(_, user)| user); if !is_visible_project(&project.inner, &user_option, &pool, false) .await diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 3ff46b06e3..f3280b1033 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -1,3 +1,4 @@ +use crate::auth::validate::get_maybe_user_from_headers; use crate::util::error::ApiContext as _; use std::collections::HashMap; @@ -56,7 +57,7 @@ pub async fn organization_projects_get( session_queue: web::Data, ) -> Result { let id = info.into_inner().0; - let current_user = get_user_from_headers( + let current_user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -64,8 +65,8 @@ pub async fn organization_projects_get( Scopes::ORGANIZATION_READ | Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let organization_data = DBOrganization::get(&id, &**pool, &redis) .await @@ -238,7 +239,7 @@ pub async fn organization_get( session_queue: web::Data, ) -> Result { let id = info.into_inner().0; - let current_user = get_user_from_headers( + let current_user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -246,8 +247,8 @@ pub async fn organization_get( Scopes::ORGANIZATION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let user_id = current_user.as_ref().map(|x| x.id.into()); let organization_data = DBOrganization::get(&id, &**pool, &redis) @@ -449,7 +450,7 @@ pub async fn organizations_get( .await .wrap_internal_err("fetching users from database")?; - let current_user = get_user_from_headers( + let current_user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -457,8 +458,8 @@ pub async fn organizations_get( Scopes::ORGANIZATION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let user_id = current_user.as_ref().map(|x| x.id.into()); let include_notes = current_user.as_ref().is_some_and(|x| x.role.is_mod()); let notes = if include_notes { diff --git a/apps/labrinth/src/routes/v3/projects/mod.rs b/apps/labrinth/src/routes/v3/projects/mod.rs index 5acae44de2..24c545119e 100644 --- a/apps/labrinth/src/routes/v3/projects/mod.rs +++ b/apps/labrinth/src/routes/v3/projects/mod.rs @@ -1,3 +1,4 @@ +use crate::auth::validate::get_maybe_user_from_headers; use crate::util::error::ApiContext as _; use std::any::type_name; use std::cmp::Reverse; @@ -259,7 +260,7 @@ pub async fn projects_get( .await .wrap_api_err("fetching requested projects")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -267,8 +268,8 @@ pub async fn projects_get( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let projects = filter_visible_projects(projects_data, &user_option, &pool, false) @@ -306,7 +307,7 @@ pub async fn project_get_internal( let project_data = db_models::DBProject::get(&string, &**pool, &redis) .await .wrap_internal_err("failed to fetch project")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -314,8 +315,8 @@ pub async fn project_get_internal( Scopes::PROJECT_READ, ) .await - .map(|(_, user)| user) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|(_, user)| user); if let Some(data) = project_data && is_visible_project(&data.inner, &user_option, &pool, false) @@ -1744,7 +1745,7 @@ pub async fn dependency_list_internal( .await .wrap_api_err("fetching project from database")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -1752,8 +1753,8 @@ pub async fn dependency_list_internal( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if let Some(project) = result { if !is_visible_project(&project.inner, &user_option, &pool, false) @@ -3710,7 +3711,7 @@ pub async fn project_get_organization( redis: web::Data, session_queue: web::Data, ) -> Result { - let current_user = get_user_from_headers( + let current_user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -3718,8 +3719,8 @@ pub async fn project_get_organization( Scopes::PROJECT_READ | Scopes::ORGANIZATION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let user_id = current_user.as_ref().map(|x| x.id.into()); let string = info.into_inner().0; diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 5cb8e173bb..78dbcf377b 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -1,5 +1,6 @@ use crate::auth::checks::{is_visible_organization, is_visible_project}; use crate::auth::get_user_from_headers; +use crate::auth::validate::get_maybe_user_from_headers; use crate::database::DBProject; use crate::database::PgPool; use crate::database::models::notification_item::NotificationBuilder; @@ -66,7 +67,7 @@ pub async fn team_members_get_project_internal( .wrap_api_err("fetching project from database")?; if let Some(project) = project_data { - let current_user = get_user_from_headers( + let current_user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -74,8 +75,8 @@ pub async fn team_members_get_project_internal( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if !is_visible_project(&project.inner, ¤t_user, &pool, false) .await @@ -155,7 +156,7 @@ pub async fn team_members_get_organization( .await .wrap_internal_err("fetching organization from database")?; - let current_user = get_user_from_headers( + let current_user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -163,8 +164,8 @@ pub async fn team_members_get_organization( Scopes::ORGANIZATION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if let Some(organization) = organization_data && is_visible_organization(&organization, ¤t_user, &pool, &redis) @@ -255,7 +256,7 @@ pub async fn team_members_get( .await .wrap_internal_err("fetching users from database")?; - let current_user = get_user_from_headers( + let current_user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -263,8 +264,8 @@ pub async fn team_members_get( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let user_id = current_user.as_ref().map(|x| x.id.into()); let logged_in = current_user @@ -346,7 +347,7 @@ pub async fn teams_get( .await .wrap_internal_err("fetching users from database")?; - let current_user = get_user_from_headers( + let current_user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -354,8 +355,8 @@ pub async fn teams_get( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let teams_groups = teams_data.into_iter().chunk_by(|data| data.team_id.0); diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 3ec7205286..5c0562be31 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -1,3 +1,5 @@ +use crate::auth::validate::get_full_user_from_headers_allow_locked; +use crate::auth::validate::get_maybe_user_from_headers; use crate::util::error::ApiContext as _; use std::{ cmp::Reverse, @@ -14,7 +16,9 @@ use crate::{ filter_visible_collections, filter_visible_projects, get_user_from_headers, }, - database::models::{DBModerationNote, DBOrganization, DBProjectId, DBUser}, + database::models::{ + DBModerationNote, DBOrganization, DBProjectId, DBUser, DBUserId, + }, file_hosting::{FileHost, FileHostPublicity}, models::{ ids::OrganizationId, @@ -22,7 +26,7 @@ use crate::{ organizations::Organization, pats::Scopes, projects::Project, - users::{Badges, Role}, + users::{Badges, Role, User}, }, queue::session::AuthQueue, util::{img::delete_old_images, routes::read_limited_from_payload}, @@ -78,7 +82,7 @@ pub async fn all_projects( redis: web::Data, session_queue: web::Data, ) -> Result, ApiError> { - let user = get_user_from_headers( + let user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -86,8 +90,8 @@ pub async fn all_projects( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let target_user = DBUser::get(&info.into_inner().0, &**pool, &redis) .await .wrap_internal_err("fetching user from database")? @@ -292,7 +296,7 @@ pub async fn projects_list( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( + let user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -300,8 +304,8 @@ pub async fn projects_list( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) .await @@ -345,7 +349,7 @@ pub async fn user_auth_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let (scopes, mut user) = get_user_from_headers( + let (scopes, db_user) = get_full_user_from_headers_allow_locked( &req, &**pool, &redis, @@ -355,6 +359,15 @@ pub async fn user_auth_get( .await .wrap_auth_err("authenticating API request")?; + let mut user = match db_user.account_locked { + false => User::from_full(db_user), + true => { + let mut user = User::from(db_user); + user.account_locked = Some(true); + return Ok(HttpResponse::Ok().json(user)); + } + }; + if !scopes.contains(Scopes::USER_READ_EMAIL) { user.email = None; } @@ -536,18 +549,20 @@ pub async fn users_get( .await .wrap_internal_err("fetching users from database")?; - let auth_user = get_user_from_headers( + let auth_user = get_maybe_user_from_headers( &req, &**pool, &redis, &session_queue, - Scopes::SESSION_ACCESS, + Scopes::empty(), ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")?; - let notes = if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { + let is_mod = auth_user.as_ref().is_some_and(|(scopes, user)| { + scopes.contains(Scopes::SESSION_ACCESS) && user.role.is_mod() + }); + let notes = if is_mod { DBModerationNote::get_many_users( &users_data.iter().map(|x| x.id).collect::>(), &**pool, @@ -559,13 +574,21 @@ pub async fn users_get( HashMap::new() }; - let users: Vec = users_data + let users: Vec = users_data .into_iter() .map(|data| { - let mut user = crate::models::users::User::from(data.clone()); - if auth_user.as_ref().is_some_and(|x| x.role.is_mod()) { + let user_id = data.id; + let visible_account_locked = auth_user + .as_ref() + .filter(|(_, viewer)| { + viewer.id == user_id.into() || viewer.role.is_admin() + }) + .map(|_| data.account_locked); + let mut user = User::from(data); + user.account_locked = visible_account_locked; + if is_mod { user.moderation_notes = - Some(notes.get(&data.id).cloned().map(Into::into)); + Some(notes.get(&user_id).cloned().map(Into::into)); } user }) @@ -598,27 +621,37 @@ pub async fn user_get( .wrap_internal_err("fetching user from database")?; if let Some(data) = user_data { - let auth_user = get_user_from_headers( + let auth_user = get_maybe_user_from_headers( &req, &**pool, &redis, &session_queue, - Scopes::SESSION_ACCESS, + Scopes::empty(), ) .await - .map(|x| x.1) - .ok(); - - let is_admin = auth_user.as_ref().is_some_and(|x| x.role.is_admin()); - let is_mod = auth_user.as_ref().is_some_and(|x| x.role.is_mod()); + .wrap_auth_err("authenticating API request")?; + + let staff_role = auth_user.as_ref().and_then(|(scopes, user)| { + scopes + .contains(Scopes::SESSION_ACCESS) + .then_some(&user.role) + }); + let is_admin = staff_role.is_some_and(Role::is_admin); + let is_mod = staff_role.is_some_and(Role::is_mod); let user_id = data.id; + let visible_account_locked = auth_user + .as_ref() + .filter(|(_, viewer)| { + viewer.id == user_id.into() || viewer.role.is_admin() + }) + .map(|_| data.account_locked); - let mut response: crate::models::users::User = if is_admin { + let mut response = if is_admin { let github_id = data.github_id.and_then(|id| u64::try_from(id).ok()); let discord_id = data.discord_id.map(|id| id.to_string()); let steam_id = data.steam_id.map(|id| id.to_string()); - let mut user = crate::models::users::User::from_full(data); + let mut user = User::from_full(data); user.github_id = github_id; user.discord_id = discord_id; user.steam_id = steam_id; @@ -627,6 +660,8 @@ pub async fn user_get( data.into() }; + response.account_locked = visible_account_locked; + if is_mod { let note = DBModerationNote::get_user(user_id, &**pool, &redis) .await @@ -735,7 +770,7 @@ pub async fn collections_list( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( + let user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -743,8 +778,8 @@ pub async fn collections_list( Scopes::COLLECTION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) .await @@ -782,7 +817,7 @@ pub async fn orgs_list( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( + let user = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -790,8 +825,8 @@ pub async fn orgs_list( Scopes::PROJECT_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) .await @@ -891,6 +926,7 @@ pub struct EditUser { #[validate(length(max = 160))] pub bio: Option>, pub role: Option, + pub account_locked: Option, pub badges: Option, #[validate(length(max = 160))] pub venmo_handle: Option, @@ -991,6 +1027,25 @@ pub async fn user_edit( .wrap_internal_err("fetching bio from database")?; } + if let Some(account_locked) = new_user.account_locked { + if !user.role.is_admin() { + return Err(ApiError::Auth(eyre::eyre!( + "only admins can edit account lock" + ))); + } + + sqlx::query!( + r#" + UPDATE users SET account_locked = $1 WHERE id = $2 + "#, + account_locked, + id as DBUserId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err("updating account lock")?; + } + if let Some(role) = &new_user.role { if !user.role.is_admin() { return Err(ApiError::Auth(eyre::eyre!( diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 79eeb462eb..307849e23e 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -1,5 +1,6 @@ use super::ApiError; use crate::auth::checks::{filter_visible_versions, is_visible_version}; +use crate::auth::validate::get_maybe_user_from_headers; use crate::auth::{filter_visible_projects, get_user_from_headers}; use crate::database::PgPool; use crate::database::ReadOnlyPgPool; @@ -72,7 +73,7 @@ pub async fn get_version_from_hash( hash_query: web::Query, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -80,8 +81,8 @@ pub async fn get_version_from_hash( Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let hash = info.into_inner().0.to_lowercase(); let algorithm = hash_query.algorithm.clone().unwrap_or_else(|| { default_algorithm_from_hashes(std::slice::from_ref(&hash)) @@ -204,7 +205,7 @@ pub async fn get_update_from_hash( update_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &***pool, &redis, @@ -212,8 +213,8 @@ pub async fn get_update_from_hash( Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let hash = info.into_inner().0.to_lowercase(); if let Some(file) = database::models::DBVersion::get_file_from_hash( hash_query.algorithm.clone().unwrap_or_else(|| { @@ -327,7 +328,7 @@ pub async fn get_versions_from_hashes( file_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &***pool, &redis, @@ -335,8 +336,8 @@ pub async fn get_versions_from_hashes( Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let algorithm = file_data .algorithm @@ -410,7 +411,7 @@ pub async fn get_projects_from_hashes( file_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -418,8 +419,8 @@ pub async fn get_projects_from_hashes( Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let algorithm = file_data .algorithm @@ -691,7 +692,7 @@ pub async fn update_individual_files( update_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -699,8 +700,8 @@ pub async fn update_individual_files( Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let algorithm = update_data.algorithm.clone().unwrap_or_else(|| { default_algorithm_from_hashes( @@ -1035,7 +1036,7 @@ pub async fn download_version( hash_query: web::Query, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -1043,8 +1044,8 @@ pub async fn download_version( Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let hash = info.into_inner().0.to_lowercase(); let algorithm = hash_query.algorithm.clone().unwrap_or_else(|| { diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 9a969bc531..38c910cc9e 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -1,3 +1,4 @@ +use crate::auth::validate::get_maybe_user_from_headers; use crate::util::error::ApiContext as _; use std::collections::HashMap; @@ -84,7 +85,7 @@ pub async fn version_project_get_helper( .await .wrap_api_err("fetching project from database")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -92,8 +93,8 @@ pub async fn version_project_get_helper( Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if let Some(project) = result { if !is_visible_project(&project.inner, &user_option, &pool, false) @@ -211,7 +212,7 @@ pub async fn versions_get( .await .wrap_internal_err("fetching versions from database")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -219,8 +220,8 @@ pub async fn versions_get( Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); let mut versions = filter_visible_versions( versions_data, @@ -293,7 +294,7 @@ pub async fn version_get_helper( .await .wrap_internal_err("fetching version from database")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -301,8 +302,8 @@ pub async fn version_get_helper( Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if let Some(data) = version_data && is_visible_version(&data.inner, &user_option, &pool, &redis) @@ -1027,7 +1028,7 @@ pub async fn version_list_internal( .await .wrap_api_err("fetching project from database")?; - let user_option = get_user_from_headers( + let user_option = get_maybe_user_from_headers( &req, &**pool, &redis, @@ -1035,8 +1036,8 @@ pub async fn version_list_internal( Scopes::PROJECT_READ | Scopes::VERSION_READ, ) .await - .map(|x| x.1) - .ok(); + .wrap_auth_err("authenticating API request")? + .map(|x| x.1); if let Some(project) = result { if !is_visible_project(&project.inner, &user_option, &pool, false) diff --git a/apps/labrinth/tests/account_locked.rs b/apps/labrinth/tests/account_locked.rs new file mode 100644 index 0000000000..d818fbf3a8 --- /dev/null +++ b/apps/labrinth/tests/account_locked.rs @@ -0,0 +1,435 @@ +#![recursion_limit = "256"] + +use actix_http::StatusCode; +use actix_web::{http::Method, test}; +use chrono::{Duration, Utc}; +use common::{ + api_common::AppendsOptionalPat, + api_v3::{ + ApiV3, + oauth::{ + get_auth_code_from_redirect_params, get_authorize_accept_flow_id, + }, + }, + database::{ADMIN_USER_PAT, FRIEND_USER_PAT, MOD_USER_PAT, USER_USER_PAT}, + environment::{TestEnvironment, with_test_environment}, +}; +use labrinth::{ + auth::{ + AuthenticationError, get_user_from_headers, + validate::{ + get_full_user_from_headers_allow_locked, + get_maybe_user_from_headers, get_user_record_from_bearer_token, + }, + }, + database::models::{ + DBUserId, flow_item::DBFlow, session_item::SessionBuilder, + }, + env::ENV, + models::pats::Scopes, + queue::session::AuthQueue, +}; +use serde_json::{Value, json}; + +pub mod common; + +async fn set_account_locked( + env: &TestEnvironment, + user: &str, + account_locked: bool, +) { + let response = env + .call( + test::TestRequest::patch() + .uri(&format!("/v3/user/{user}")) + .append_pat(ADMIN_USER_PAT) + .set_json(json!({"account_locked": account_locked})) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::NO_CONTENT); +} + +#[actix_rt::test] +async fn account_locked_is_visible_only_to_self_and_admin_in_both_api_versions() +{ + with_test_environment(None, |env: TestEnvironment| async move { + let mut restricted_tokens = Vec::new(); + for pat in [USER_USER_PAT, ADMIN_USER_PAT] { + let response = env + .call( + test::TestRequest::post() + .uri("/_internal/pat") + .append_pat(pat) + .set_json(json!({ + "name": "account lock visibility", + "scopes": Scopes::empty(), + "expires": Utc::now() + Duration::days(1), + })) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + let body: Value = test::read_body_json(response).await; + restricted_tokens + .push(body["access_token"].as_str().unwrap().to_owned()); + } + for account_locked in [false, true] { + set_account_locked(&env, "3", account_locked).await; + for version in ["v2", "v3"] { + for (pat, visible) in [ + (None, false), + (FRIEND_USER_PAT, false), + (MOD_USER_PAT, false), + (USER_USER_PAT, true), + (ADMIN_USER_PAT, true), + (Some(restricted_tokens[0].as_str()), true), + (Some(restricted_tokens[1].as_str()), true), + ] { + let locked_viewer = account_locked + && (pat == USER_USER_PAT + || pat == Some(restricted_tokens[0].as_str())); + for target in ["3", "User"] { + let response = env + .call( + test::TestRequest::get() + .uri(&format!("/{version}/user/{target}")) + .append_pat(pat) + .to_request(), + ) + .await; + if locked_viewer { + assert_status!(&response, StatusCode::FORBIDDEN); + continue; + } + assert_status!(&response, StatusCode::OK); + let body: Value = test::read_body_json(response).await; + assert_eq!( + body.get("account_locked"), + visible.then(|| json!(account_locked)).as_ref() + ); + assert_eq!( + body["email"].is_string(), + pat == ADMIN_USER_PAT + ); + if restricted_tokens + .iter() + .any(|token| pat == Some(token.as_str())) + { + for field in [ + "github_id", + "discord_id", + "steam_id", + "moderation_notes", + "payout_data", + ] { + assert!( + body[field].is_null(), + "{field} requires session access" + ); + } + } + } + + let response = env + .call( + test::TestRequest::get() + .uri(&format!( + "/{version}/users?ids=%5B%223%22,%225%22%5D" + )) + .append_pat(pat) + .to_request(), + ) + .await; + if locked_viewer { + assert_status!(&response, StatusCode::FORBIDDEN); + continue; + } + assert_status!(&response, StatusCode::OK); + let body: Vec = test::read_body_json(response).await; + assert_eq!(body.len(), 2); + for user in body { + let expected = if user["id"] == "3" { + visible.then(|| json!(account_locked)) + } else { + (pat == ADMIN_USER_PAT + || pat == Some(restricted_tokens[1].as_str())) + .then(|| json!(false)) + }; + assert_eq!( + user.get("account_locked"), + expected.as_ref() + ); + } + } + let response = env + .call( + test::TestRequest::get() + .uri(&format!("/{version}/user")) + .append_pat(USER_USER_PAT) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + let body: Value = test::read_body_json(response).await; + assert_eq!(body["account_locked"], json!(account_locked)); + match account_locked { + false => assert!(body["email"].is_string()), + true => { + for field in [ + "email", + "email_verified", + "auth_providers", + "has_password", + "has_totp", + "payout_data", + "stripe_customer_id", + "allow_friend_requests", + "eligibility_verified_at", + "github_id", + "discord_id", + "steam_id", + "moderation_notes", + ] { + assert!( + body[field].is_null(), + "{field} must not be exposed to a locked account" + ); + } + } + } + } + } + }) + .await; +} + +#[actix_rt::test] +async fn existing_pat_session_and_oauth_credentials_obey_account_locks_and_cache_invalidation() + { + with_test_environment(None, |env: TestEnvironment| async move { + let client = &env.dummy.oauth_client_alpha; + let oauth_token = env + .api + .complete_full_authorize_flow( + &client.client_id, + &client.client_secret, + Some("USER_READ USER_WRITE"), + None, + None, + USER_USER_PAT, + ) + .await; + let mut transaction = env.db.pool.begin().await.unwrap(); + SessionBuilder { + session: "mra_account_locked_test".into(), + user_id: DBUserId(3), + os: None, + platform: None, + city: None, + country: None, + ip: "127.0.0.1".into(), + user_agent: "account lock test".into(), + expires: None, + session_expires: None, + } + .insert(&mut transaction) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let tokens = [ + USER_USER_PAT.unwrap(), + "mra_account_locked_test", + oauth_token.as_str(), + ]; + let queue = AuthQueue::new(); + for account_locked in [false, true, false] { + set_account_locked(&env, "3", account_locked).await; + for token in tokens { + let req = test::TestRequest::get() + .append_pat(Some(token)) + .append_header(( + "x-ratelimit-key", + ENV.RATE_LIMIT_IGNORE_KEY.as_str(), + )) + .to_http_request(); + let result = get_user_from_headers( + &req, + &*env.db.pool, + &env.db.redis_pool, + &queue, + Scopes::USER_READ, + ) + .await; + match account_locked { + true => assert!(matches!( + result, + Err(AuthenticationError::AccountLocked) + )), + false => assert_eq!( + result.unwrap().1.account_locked, + Some(false) + ), + } + let (_, user) = get_full_user_from_headers_allow_locked( + &req, + &*env.db.pool, + &env.db.redis_pool, + &queue, + Scopes::USER_READ, + ) + .await + .unwrap(); + assert_eq!(user.account_locked, account_locked); + let response = env + .call( + test::TestRequest::get() + .uri("/v3/user") + .append_pat(Some(token)) + .append_header(( + "x-ratelimit-key", + ENV.RATE_LIMIT_IGNORE_KEY.as_str(), + )) + .to_request(), + ) + .await; + assert_status!(&response, StatusCode::OK); + let body: Value = test::read_body_json(response).await; + assert_eq!(body["account_locked"], json!(account_locked)); + if account_locked { + assert!(body["auth_providers"].is_null()); + assert!(body["email"].is_null()); + assert!(matches!( + get_maybe_user_from_headers( + &req, + &*env.db.pool, + &env.db.redis_pool, + &queue, + Scopes::SESSION_ACCESS, + ) + .await, + Err(AuthenticationError::AccountLocked) + )); + assert!(matches!( + get_user_record_from_bearer_token( + &req, + Some(token), + &*env.db.pool, + &env.db.redis_pool, + &queue, + true, + ) + .await, + Err(AuthenticationError::AccountLocked) + )); + } + } + } + let req = test::TestRequest::get() + .append_pat(Some(&oauth_token)) + .to_http_request(); + let result = get_full_user_from_headers_allow_locked( + &req, + &*env.db.pool, + &env.db.redis_pool, + &queue, + Scopes::USER_AUTH_WRITE, + ) + .await; + assert!(matches!( + result, + Err(AuthenticationError::InvalidCredentials) + )); + }) + .await; +} + +#[actix_rt::test] +async fn locked_accounts_cannot_mutate_resources_read_sensitive_data_or_unlock_themselves() + { + with_test_environment(None, |env: TestEnvironment| async move { + for pat in [USER_USER_PAT, MOD_USER_PAT] { + let response = env.call(test::TestRequest::patch().uri("/v3/user/3") + .append_pat(pat).set_json(json!({"account_locked": true})).to_request()).await; + assert_status!(&response, StatusCode::UNAUTHORIZED); + } + set_account_locked(&env, "3", true).await; + let project = format!("/v3/project/{}", env.dummy.project_alpha.project_id); + let validation = format!("{project}/validate"); + for (method, uri, body) in [ + (Method::GET, project.as_str(), json!(null)), + (Method::GET, validation.as_str(), json!(null)), + (Method::GET, "/v3/user/3/projects", json!(null)), + (Method::GET, "/v3/organizations?ids=%5B%5D", json!(null)), + (Method::GET, "/v3/collections?ids=%5B%5D", json!(null)), + (Method::GET, "/v3/versions?ids=%5B%5D", json!(null)), + (Method::POST, "/analytics/view", json!({"url":"https://modrinth.com/"})), + (Method::PATCH, "/v3/user/3", json!({"account_locked":false})), + (Method::PATCH, project.as_str(), json!({"title":"changed"})), + (Method::PATCH, "/v3/organization/missing", json!({"name":"changed"})), + (Method::PATCH, "/v3/collection/missing", json!({"name":"changed"})), + (Method::GET, "/_internal/pat", json!(null)), + (Method::POST, "/_internal/pat", json!({"name":"locked token", "scopes":0, "expires":"2099-01-01T00:00:00Z"})), + (Method::GET, "/_internal/billing/subscriptions", json!(null)), + (Method::GET, "/_internal/billing/payment_methods", json!(null)), + ] { + let response = env.call(test::TestRequest::default().method(method).uri(uri) + .append_pat(USER_USER_PAT).set_json(body).to_request()).await; + assert_status!(&response, StatusCode::FORBIDDEN); + let body: Value = test::read_body_json(response).await; + assert_eq!(body["error"], "auth_error", "{uri}"); + } + for pat in [None, Some("mrp_invalid")] { + let response = env.call(test::TestRequest::get().uri(&project) + .append_pat(pat).to_request()).await; + assert_status!(&response, StatusCode::OK); + } + set_account_locked(&env, "1", true).await; + let response = env.call(test::TestRequest::patch().uri("/v3/user/3") + .append_pat(ADMIN_USER_PAT).set_json(json!({"account_locked":false})).to_request()).await; + assert_status!(&response, StatusCode::FORBIDDEN); + for version in ["v2", "v3"] { + let response = env.call(test::TestRequest::get() + .uri(&format!("/{version}/user")) + .append_pat(ADMIN_USER_PAT).to_request()).await; + assert_status!(&response, StatusCode::OK); + let body: Value = test::read_body_json(response).await; + assert_eq!(body["account_locked"], true); + for field in ["email", "auth_providers", "github_id", "discord_id", "steam_id", "payout_data", "moderation_notes"] { + assert!(body[field].is_null(), "{field} must not be exposed to a locked admin"); + } + } + }).await; +} + +#[actix_rt::test] +async fn flows_created_before_lock_cannot_reset_password_verify_email_or_issue_tokens() + { + with_test_environment(None, |env: TestEnvironment| async move { + let password_flow = DBFlow::ForgotPassword { user_id: DBUserId(3) }.insert(Duration::hours(1), &env.db.redis_pool).await.unwrap(); + let email_flow = DBFlow::ConfirmEmail { user_id: DBUserId(3), confirm_email: "user@modrinth.com".into() }.insert(Duration::hours(1), &env.db.redis_pool).await.unwrap(); + let login_flow = DBFlow::Login2FA { user_id: DBUserId(3) }.insert(Duration::hours(1), &env.db.redis_pool).await.unwrap(); + let client = &env.dummy.oauth_client_alpha; + let response = env.api.oauth_authorize(&client.client_id, Some("USER_READ"), None, None, USER_USER_PAT).await; + let flow = get_authorize_accept_flow_id(response).await; + let response = env.api.oauth_accept(&flow, USER_USER_PAT).await; + let auth_code = get_auth_code_from_redirect_params(&response).await; + set_account_locked(&env, "3", true).await; + + for (method, uri, body) in [ + (Method::PATCH, "/_internal/auth/password", json!({"flow":password_flow,"new_password":"a long secure test password 489313"})), + (Method::POST, "/_internal/auth/email/verify", json!({"flow":email_flow})), + (Method::POST, "/_internal/auth/login/2fa", json!({"flow":login_flow,"code":"123456"})), + ] { + let response = env.call(test::TestRequest::default().method(method).uri(uri).set_json(body).to_request()).await; + assert_status!(&response, StatusCode::FORBIDDEN); + let body: Value = test::read_body_json(response).await; + assert_eq!(body["error"], "auth_error"); + } + let response = env.api.oauth_token(auth_code, None, client.client_id.clone(), &client.client_secret).await; + assert_status!(&response, StatusCode::BAD_REQUEST); + let body: Value = test::read_body_json(response).await; + assert_eq!(body["error"], "invalid_grant"); + assert!(body.get("access_token").is_none()); + }).await; +}