diff --git a/crates/crates_io_trustpub/src/access_token.rs b/crates/crates_io_trustpub/src/access_token.rs index 46b2877e348..ba33fbf3574 100644 --- a/crates/crates_io_trustpub/src/access_token.rs +++ b/crates/crates_io_trustpub/src/access_token.rs @@ -80,11 +80,15 @@ impl FromStr for AccessToken { } /// The error type for parsing access tokens. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum AccessTokenError { + #[error("Missing prefix `{}`", AccessToken::PREFIX)] MissingPrefix, + #[error("Invalid token length")] InvalidLength, + #[error("Invalid character in token")] InvalidCharacter, + #[error("Invalid checksum: claimed `{claimed}`, actual `{actual}`")] InvalidChecksum { claimed: char, actual: char }, } diff --git a/src/auth.rs b/src/auth.rs deleted file mode 100644 index 53c60d5c191..00000000000 --- a/src/auth.rs +++ /dev/null @@ -1,420 +0,0 @@ -use crate::controllers; -use crate::controllers::util::RequestPartsExt; -use crate::middleware::log_request::RequestLogExt; -use crate::models::token::{CrateScope, EndpointScope}; -use crate::models::{ApiToken, User}; -use crate::util::errors::{ - AppResult, BoxedAppError, InsecurelyGeneratedTokenRevoked, account_locked, custom, forbidden, - internal, -}; -use crate::util::token::HashedToken; -use axum::extract::FromRequestParts; -use chrono::Utc; -use crates_io_session::SessionExtension; -use diesel_async::AsyncPgConnection; -use http::request::Parts; -use http::{StatusCode, header}; -use secrecy::{ExposeSecret, SecretString}; - -pub struct AuthHeader(SecretString); - -impl AuthHeader { - pub async fn optional_from_request_parts(parts: &Parts) -> Result, BoxedAppError> { - let Some(auth_header) = parts.headers.get(header::AUTHORIZATION) else { - return Ok(None); - }; - - let auth_header = auth_header.to_str().map_err(|_| { - let message = "Invalid `Authorization` header: Found unexpected non-ASCII characters"; - custom(StatusCode::UNAUTHORIZED, message) - })?; - - let (scheme, token) = auth_header.split_once(' ').unwrap_or(("", auth_header)); - if !(scheme.eq_ignore_ascii_case("Bearer") || scheme.is_empty()) { - let message = format!( - "Invalid `Authorization` header: Found unexpected authentication scheme `{scheme}`" - ); - return Err(custom(StatusCode::UNAUTHORIZED, message)); - } - - let token = SecretString::from(token.trim_ascii()); - Ok(Some(AuthHeader(token))) - } - - pub async fn from_request_parts(parts: &Parts) -> Result { - let auth = Self::optional_from_request_parts(parts).await?; - auth.ok_or_else(|| { - let message = "Missing `Authorization` header"; - custom(StatusCode::UNAUTHORIZED, message) - }) - } - - pub fn token(&self) -> &SecretString { - &self.0 - } -} - -impl FromRequestParts for AuthHeader { - type Rejection = BoxedAppError; - - async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { - Self::from_request_parts(parts).await - } -} - -#[derive(Debug, Clone)] -pub struct AuthCheck { - allow_token: bool, - endpoint_scope: Option, - crate_name: Option, -} - -impl AuthCheck { - #[must_use] - // #[must_use] can't be applied in the `Default` trait impl - #[allow(clippy::should_implement_trait)] - pub fn default() -> Self { - Self { - allow_token: true, - endpoint_scope: None, - crate_name: None, - } - } - - #[must_use] - pub fn only_cookie() -> Self { - Self { - allow_token: false, - endpoint_scope: None, - crate_name: None, - } - } - - pub fn with_endpoint_scope(&self, endpoint_scope: EndpointScope) -> Self { - Self { - allow_token: self.allow_token, - endpoint_scope: Some(endpoint_scope), - crate_name: self.crate_name.clone(), - } - } - - pub fn for_crate(&self, crate_name: &str) -> Self { - Self { - allow_token: self.allow_token, - endpoint_scope: self.endpoint_scope, - crate_name: Some(crate_name.to_string()), - } - } - - #[instrument(name = "auth.check", skip_all)] - pub async fn check( - &self, - parts: &Parts, - conn: &mut AsyncPgConnection, - ) -> AppResult { - let auth = authenticate(parts, conn).await?; - - if let Some(token) = auth.api_token() { - if !self.allow_token { - let error_message = - "API Token authentication was explicitly disallowed for this API"; - parts.request_log().add("cause", error_message); - - return Err(forbidden( - "this action can only be performed on the crates.io website", - )); - } - - if !self.endpoint_scope_matches(token.endpoint_scopes.as_ref()) { - let error_message = "Endpoint scope mismatch"; - parts.request_log().add("cause", error_message); - - return Err(forbidden( - "this token does not have the required permissions to perform this action", - )); - } - - if !self.crate_scope_matches(token.crate_scopes.as_ref()) { - let error_message = "Crate scope mismatch"; - parts.request_log().add("cause", error_message); - - return Err(forbidden( - "this token does not have the required permissions to perform this action", - )); - } - } - - Ok(auth) - } - - fn endpoint_scope_matches(&self, token_scopes: Option<&Vec>) -> bool { - match (&token_scopes, &self.endpoint_scope) { - // The token is a legacy token. - (None, _) => true, - - // The token is NOT a legacy token, and the endpoint only allows legacy tokens. - (Some(_), None) => false, - - // The token is NOT a legacy token, and the endpoint allows a certain endpoint scope or a legacy token. - (Some(token_scopes), Some(endpoint_scope)) => token_scopes.contains(endpoint_scope), - } - } - - fn crate_scope_matches(&self, token_scopes: Option<&Vec>) -> bool { - match (&token_scopes, &self.crate_name) { - // The token is a legacy token. - (None, _) => true, - - // The token does not have any crate scopes. - (Some(token_scopes), _) if token_scopes.is_empty() => true, - - // The token has crate scopes, but the endpoint does not deal with crates. - (Some(_), None) => false, - - // The token is NOT a legacy token, and the endpoint allows a certain endpoint scope or a legacy token. - (Some(token_scopes), Some(crate_name)) => token_scopes - .iter() - .any(|token_scope| token_scope.matches(crate_name)), - } - } -} - -#[derive(Debug)] -pub enum Authentication { - Cookie(CookieAuthentication), - Token(TokenAuthentication), -} - -#[derive(Debug)] -pub struct CookieAuthentication { - user: User, -} - -#[derive(Debug)] -pub struct TokenAuthentication { - token: ApiToken, - user: User, -} - -impl Authentication { - pub fn user_id(&self) -> i32 { - self.user().id - } - - pub fn api_token_id(&self) -> Option { - self.api_token().map(|token| token.id) - } - - pub fn api_token(&self) -> Option<&ApiToken> { - match self { - Authentication::Token(token) => Some(&token.token), - _ => None, - } - } - - pub fn user(&self) -> &User { - match self { - Authentication::Cookie(cookie) => &cookie.user, - Authentication::Token(token) => &token.user, - } - } -} - -#[instrument(skip_all)] -async fn authenticate_via_cookie( - parts: &Parts, - conn: &mut AsyncPgConnection, -) -> AppResult> { - let session = parts - .extensions() - .get::() - .expect("missing cookie session"); - - let user_id_from_session = session.get("user_id").and_then(|s| s.parse::().ok()); - let Some(id) = user_id_from_session else { - return Ok(None); - }; - - let user = User::find(conn, id).await.map_err(|err| { - parts.request_log().add("cause", err); - internal("user_id from cookie not found in database") - })?; - - ensure_not_locked(&user)?; - - parts.request_log().add("uid", id); - - Ok(Some(CookieAuthentication { user })) -} - -#[instrument(skip_all)] -async fn authenticate_via_token( - parts: &Parts, - conn: &mut AsyncPgConnection, -) -> AppResult> { - let Some(auth_header) = AuthHeader::optional_from_request_parts(parts).await? else { - return Ok(None); - }; - - let token = auth_header.token().expose_secret(); - let token = HashedToken::parse(token).map_err(|_| InsecurelyGeneratedTokenRevoked::boxed())?; - - let token = ApiToken::find_by_api_token(conn, &token) - .await - .map_err(|e| { - let cause = format!("invalid token caused by {e}"); - parts.request_log().add("cause", cause); - - forbidden("authentication failed") - })?; - - let user = User::find(conn, token.user_id).await.map_err(|err| { - parts.request_log().add("cause", err); - internal("user_id from token not found in database") - })?; - - ensure_not_locked(&user)?; - - parts.request_log().add("uid", token.user_id); - parts.request_log().add("tokenid", token.id); - - Ok(Some(TokenAuthentication { user, token })) -} - -#[instrument(skip_all)] -async fn authenticate(parts: &Parts, conn: &mut AsyncPgConnection) -> AppResult { - controllers::util::verify_origin(parts)?; - - match authenticate_via_cookie(parts, conn).await { - Ok(None) => {} - Ok(Some(auth)) => return Ok(Authentication::Cookie(auth)), - Err(err) => return Err(err), - } - - match authenticate_via_token(parts, conn).await { - Ok(None) => {} - Ok(Some(auth)) => return Ok(Authentication::Token(auth)), - Err(err) => return Err(err), - } - - // Unable to authenticate the user - let cause = "no cookie session or auth header found"; - parts.request_log().add("cause", cause); - - return Err(forbidden("this action requires authentication")); -} - -fn ensure_not_locked(user: &User) -> AppResult<()> { - if let Some(reason) = &user.account_lock_reason { - let still_locked = user - .account_lock_until - .map(|until| until > Utc::now()) - .unwrap_or(true); - - if still_locked { - return Err(account_locked(reason, user.account_lock_until)); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cs(scope: &str) -> CrateScope { - CrateScope::try_from(scope).unwrap() - } - - #[test] - fn regular_endpoint() { - let auth_check = AuthCheck::default(); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - } - - #[test] - fn publish_new_endpoint() { - let auth_check = AuthCheck::default() - .with_endpoint_scope(EndpointScope::PublishNew) - .for_crate("tokio-console"); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); - } - - #[test] - fn publish_update_endpoint() { - let auth_check = AuthCheck::default() - .with_endpoint_scope(EndpointScope::PublishUpdate) - .for_crate("tokio-console"); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); - } - - #[test] - fn yank_endpoint() { - let auth_check = AuthCheck::default() - .with_endpoint_scope(EndpointScope::Yank) - .for_crate("tokio-console"); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); - } - - #[test] - fn owner_change_endpoint() { - let auth_check = AuthCheck::default() - .with_endpoint_scope(EndpointScope::ChangeOwners) - .for_crate("tokio-console"); - - assert!(auth_check.endpoint_scope_matches(None)); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishNew]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::PublishUpdate]))); - assert!(!auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::Yank]))); - assert!(auth_check.endpoint_scope_matches(Some(&vec![EndpointScope::ChangeOwners]))); - - assert!(auth_check.crate_scope_matches(None)); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-console")]))); - assert!(auth_check.crate_scope_matches(Some(&vec![cs("tokio-*")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("anyhow")]))); - assert!(!auth_check.crate_scope_matches(Some(&vec![cs("actix-*")]))); - } -} diff --git a/src/auth/authorization/entity.rs b/src/auth/authorization/entity.rs new file mode 100644 index 00000000000..ea6848a95bc --- /dev/null +++ b/src/auth/authorization/entity.rs @@ -0,0 +1,25 @@ +use crate::auth::authorization::trustpub::AuthorizedTrustPub; +use crate::auth::authorization::user::AuthorizedUser; +use crates_io_database::models::{ApiToken, User}; + +pub enum AuthorizedEntity { + User(Box>>), + TrustPub(AuthorizedTrustPub), +} + +impl AuthorizedEntity { + pub fn user_auth(&self) -> Option<&AuthorizedUser>> { + match self { + AuthorizedEntity::User(auth) => Some(auth), + AuthorizedEntity::TrustPub(_) => None, + } + } + + pub fn user(&self) -> Option<&User> { + self.user_auth().map(|auth| auth.user()) + } + + pub fn user_id(&self) -> Option { + self.user_auth().map(|auth| auth.user_id()) + } +} diff --git a/src/auth/authorization/mod.rs b/src/auth/authorization/mod.rs new file mode 100644 index 00000000000..811ea5f2671 --- /dev/null +++ b/src/auth/authorization/mod.rs @@ -0,0 +1,9 @@ +mod entity; +mod permission; +mod trustpub; +mod user; + +pub use self::entity::*; +pub use self::permission::*; +pub use self::trustpub::*; +pub use self::user::*; diff --git a/src/auth/authorization/permission.rs b/src/auth/authorization/permission.rs new file mode 100644 index 00000000000..6815152f371 --- /dev/null +++ b/src/auth/authorization/permission.rs @@ -0,0 +1,76 @@ +use crates_io_database::models::{Crate, Owner}; + +pub enum Permission<'a> { + ListApiTokens, + CreateApiToken, + ReadApiToken, + RevokeApiToken, + RevokeCurrentApiToken, + + PublishNew { + name: &'a str, + }, + PublishUpdate { + krate: &'a Crate, + }, + DeleteCrate { + krate: &'a Crate, + owners: &'a [Owner], + }, + + ModifyOwners { + krate: &'a Crate, + owners: &'a [Owner], + }, + + ListCrateOwnerInvitations, + ListOwnCrateOwnerInvitations, + HandleCrateOwnerInvitation, + + ListFollowedCrates, + ReadFollowState, + FollowCrate, + UnfollowCrate, + + ListTrustPubGitHubConfigs { + krate: &'a Crate, + }, + CreateTrustPubGitHubConfig { + user_owner_ids: Vec, + }, + DeleteTrustPubGitHubConfig { + user_owner_ids: Vec, + }, + + ReadUser, + UpdateUser, + + UpdateVersion { + krate: &'a Crate, + }, + YankVersion { + krate: &'a Crate, + }, + UnyankVersion { + krate: &'a Crate, + }, + + ResendEmailVerification, + UpdateEmailNotifications, + ListUpdates, + + RebuildDocs { + krate: &'a Crate, + }, +} + +impl Permission<'_> { + #[allow(clippy::match_like_matches_macro)] + pub(in crate::auth) fn allowed_for_admin(&self) -> bool { + match self { + Permission::YankVersion { .. } => true, + Permission::UnyankVersion { .. } => true, + _ => false, + } + } +} diff --git a/src/auth/authorization/trustpub.rs b/src/auth/authorization/trustpub.rs new file mode 100644 index 00000000000..0f5268118a2 --- /dev/null +++ b/src/auth/authorization/trustpub.rs @@ -0,0 +1,37 @@ +use crate::auth::Permission; +use crate::util::errors::{BoxedAppError, forbidden}; + +pub struct AuthorizedTrustPub { + crate_ids: Vec, +} + +impl AuthorizedTrustPub { + pub fn new(crate_ids: Vec) -> Self { + Self { crate_ids } + } + + pub(in crate::auth) async fn validate( + self, + permission: Permission<'_>, + ) -> Result { + let existing_crate = match permission { + Permission::PublishUpdate { krate } => krate, + Permission::PublishNew { .. } => { + let message = "Trusted Publishing tokens do not support creating new crates"; + return Err(forbidden(message)); + } + _ => { + let message = "Trusted Publishing tokens can only be used for publishing crates"; + return Err(forbidden(message)); + } + }; + + if !self.crate_ids.contains(&existing_crate.id) { + let name = &existing_crate.name; + let error = format!("The provided access token is not valid for crate `{name}`"); + return Err(forbidden(error)); + } + + Ok(self) + } +} diff --git a/src/auth/authorization/user.rs b/src/auth/authorization/user.rs new file mode 100644 index 00000000000..dbb899d1d79 --- /dev/null +++ b/src/auth/authorization/user.rs @@ -0,0 +1,324 @@ +use crate::auth::Permission; +use crate::controllers::helpers::authorization::Rights; +use crate::middleware::app::RequestApp; +use crate::middleware::log_request::RequestLogExt; +use crate::util::errors::{BoxedAppError, account_locked, bad_request, forbidden}; +use chrono::Utc; +use crates_io_database::models::token::EndpointScope; +use crates_io_database::models::{ApiToken, OwnerKind, User}; +use crates_io_database::schema::crate_owners; +use diesel::dsl::exists; +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use http::request::Parts; + +pub struct AuthorizedUser { + user: User, + api_token: T, +} + +impl AuthorizedUser { + pub fn new(user: User, api_token: T) -> Self { + AuthorizedUser { user, api_token } + } + + pub fn user(&self) -> &User { + &self.user + } + + pub fn user_id(&self) -> i32 { + self.user.id + } + + fn check_user_locked(&self) -> Result<(), BoxedAppError> { + ensure_not_locked(&self.user) + } + + async fn check_email_verification( + &self, + conn: &mut AsyncPgConnection, + permission: &Permission<'_>, + ) -> Result<(), BoxedAppError> { + if self.user.verified_email(conn).await?.is_some() { + return Ok(()); + } + + match permission { + Permission::PublishNew { .. } | Permission::PublishUpdate { .. } => Err(bad_request( + "A verified email address is required to publish crates to crates.io. Visit https://crates.io/settings/profile to set and verify your email address.", + )), + Permission::CreateTrustPubGitHubConfig { .. } => Err(forbidden( + "You must verify your email address to create a Trusted Publishing config", + )), + _ => Ok(()), + } + } + + async fn check_crate_rights( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: &Permission<'_>, + ) -> Result<(), BoxedAppError> { + match permission { + Permission::PublishUpdate { krate } => { + const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem to be an owner. \ + If you believe this is a mistake, perhaps you need \ + to accept an invitation to be an owner before publishing."; + + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden(MISSING_RIGHTS_ERROR_MESSAGE)); + } + } + Permission::DeleteCrate { owners, .. } => { + match Rights::get(&self.user, &*parts.app().github, owners).await? { + Rights::Full => {} + Rights::Publish => { + return Err(forbidden( + "team members don't have permission to delete crates", + )); + } + Rights::None => { + return Err(forbidden("only owners have permission to delete crates")); + } + } + } + Permission::ModifyOwners { owners, .. } => { + match Rights::get(&self.user, &*parts.app().github, owners).await? { + Rights::Full => {} + Rights::Publish => { + return Err(forbidden( + "team members don't have permission to modify owners", + )); + } + Rights::None => { + return Err(forbidden("only owners have permission to modify owners")); + } + } + } + Permission::ListTrustPubGitHubConfigs { krate } => { + let is_owner = diesel::select(exists( + crate_owners::table + .filter(crate_owners::crate_id.eq(krate.id)) + .filter(crate_owners::deleted.eq(false)) + .filter(crate_owners::owner_kind.eq(OwnerKind::User)) + .filter(crate_owners::owner_id.eq(self.user.id)), + )) + .get_result::(conn) + .await?; + + if !is_owner { + return Err(bad_request("You are not an owner of this crate")); + } + } + Permission::CreateTrustPubGitHubConfig { user_owner_ids } => { + if user_owner_ids.iter().all(|id| *id != self.user.id) { + return Err(bad_request("You are not an owner of this crate")); + } + } + Permission::DeleteTrustPubGitHubConfig { user_owner_ids } => { + if user_owner_ids.iter().all(|id| *id != self.user.id) { + return Err(bad_request("You are not an owner of this crate")); + } + } + Permission::UpdateVersion { krate } => { + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden("must already be an owner to yank or unyank")); + } + } + Permission::YankVersion { krate } => { + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden("must already be an owner to yank or unyank")); + } + } + Permission::UnyankVersion { krate } => { + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden("must already be an owner to yank or unyank")); + } + } + Permission::RebuildDocs { krate } => { + let owners = krate.owners(conn).await?; + if Rights::get(&self.user, &*parts.app().github, &owners).await? < Rights::Publish { + return Err(forbidden( + "user doesn't have permission to trigger a docs rebuild", + )); + } + } + _ => {} + } + + Ok(()) + } +} + +impl AuthorizedUser<()> { + pub(in crate::auth) async fn validate( + self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result { + if self.user.is_admin && permission.allowed_for_admin() { + return Ok(self); + } + + self.check_user_locked()?; + self.check_email_verification(conn, &permission).await?; + self.check_crate_rights(conn, parts, &permission).await?; + + Ok(self) + } +} + +impl AuthorizedUser { + pub fn api_token(&self) -> &ApiToken { + &self.api_token + } + + pub fn api_token_id(&self) -> i32 { + self.api_token.id + } + + pub(in crate::auth) async fn validate( + self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result { + if self.user.is_admin && permission.allowed_for_admin() { + return Ok(self); + } + + self.check_user_locked()?; + self.check_email_verification(conn, &permission).await?; + self.check_crate_rights(conn, parts, &permission).await?; + self.check_token_scopes(parts, &permission).await?; + + Ok(self) + } + + async fn check_token_scopes( + &self, + parts: &Parts, + permission: &Permission<'_>, + ) -> Result<(), BoxedAppError> { + let (endpoint_scope, crate_name) = match permission { + Permission::PublishNew { name } => (Some(EndpointScope::PublishNew), Some(*name)), + Permission::PublishUpdate { krate } => ( + Some(EndpointScope::PublishUpdate), + Some(krate.name.as_str()), + ), + Permission::ModifyOwners { krate, .. } => { + (Some(EndpointScope::ChangeOwners), Some(krate.name.as_str())) + } + Permission::UpdateVersion { krate } => { + (Some(EndpointScope::Yank), Some(krate.name.as_str())) + } + Permission::YankVersion { krate } => { + (Some(EndpointScope::Yank), Some(krate.name.as_str())) + } + Permission::UnyankVersion { krate } => { + (Some(EndpointScope::Yank), Some(krate.name.as_str())) + } + _ => (None, None), + }; + + if !endpoint_scope_matches(endpoint_scope, &self.api_token) { + let error_message = "Endpoint scope mismatch"; + parts.request_log().add("cause", error_message); + + return Err(forbidden( + "this token does not have the required permissions to perform this action", + )); + } + + if !crate_scope_matches(crate_name, &self.api_token) { + let error_message = "Crate scope mismatch"; + parts.request_log().add("cause", error_message); + + return Err(forbidden( + "this token does not have the required permissions to perform this action", + )); + } + + Ok(()) + } +} + +impl AuthorizedUser> { + pub fn api_token(&self) -> Option<&ApiToken> { + self.api_token.as_ref() + } + + pub fn api_token_id(&self) -> Option { + self.api_token.as_ref().map(|token| token.id) + } +} + +impl From> for AuthorizedUser> { + fn from(auth: AuthorizedUser<()>) -> Self { + AuthorizedUser { + user: auth.user, + api_token: None, + } + } +} + +impl From> for AuthorizedUser> { + fn from(auth: AuthorizedUser) -> Self { + AuthorizedUser { + user: auth.user, + api_token: Some(auth.api_token), + } + } +} + +fn ensure_not_locked(user: &User) -> Result<(), BoxedAppError> { + if let Some(reason) = &user.account_lock_reason { + let still_locked = user + .account_lock_until + .map(|until| until > Utc::now()) + .unwrap_or(true); + + if still_locked { + return Err(account_locked(reason, user.account_lock_until)); + } + } + + Ok(()) +} + +fn endpoint_scope_matches(endpoint_scope: Option, token: &ApiToken) -> bool { + match (&token.endpoint_scopes, endpoint_scope) { + // The token is a legacy token. + (None, _) => true, + + // The token is NOT a legacy token, and the endpoint only allows legacy tokens. + (Some(_), None) => false, + + // The token is NOT a legacy token, and the endpoint allows a certain endpoint scope or a legacy token. + (Some(token_scopes), Some(endpoint_scope)) => token_scopes.contains(&endpoint_scope), + } +} + +fn crate_scope_matches(crate_name: Option<&str>, token: &ApiToken) -> bool { + match (&token.crate_scopes, &crate_name) { + // The token is a legacy token. + (None, _) => true, + + // The token does not have any crate scopes. + (Some(token_scopes), _) if token_scopes.is_empty() => true, + + // The token has crate scopes, but the endpoint does not deal with crates. + (Some(_), None) => false, + + // The token is NOT a legacy token, and the endpoint allows a certain endpoint scope or a legacy token. + (Some(token_scopes), Some(crate_name)) => token_scopes + .iter() + .any(|token_scope| token_scope.matches(crate_name)), + } +} diff --git a/src/auth/credentials/api_token.rs b/src/auth/credentials/api_token.rs new file mode 100644 index 00000000000..be9b441cfc7 --- /dev/null +++ b/src/auth/credentials/api_token.rs @@ -0,0 +1,107 @@ +use crate::auth::{AuthorizedUser, Permission}; +use crate::controllers; +use crate::middleware::log_request::RequestLogExt; +use crate::util::errors::{ + BoxedAppError, InsecurelyGeneratedTokenRevoked, bad_request, custom, forbidden, internal, +}; +use axum::extract::FromRequestParts; +use crates_io_database::models::{ApiToken, User}; +use crates_io_database::utils::token::{HashedToken, InvalidTokenError}; +use diesel_async::AsyncPgConnection; +use http::header::ToStrError; +use http::request::Parts; +use http::{HeaderValue, StatusCode}; + +#[derive(Debug)] +pub struct ApiTokenCredentials { + hashed_token: HashedToken, +} + +impl ApiTokenCredentials { + pub fn from_request_parts(parts: &Parts) -> Result { + use ApiTokenCredentialsError::*; + use http::header; + + let header = parts.headers.get(header::AUTHORIZATION); + let header = header.ok_or(MissingAuthorizationHeader)?; + + Self::from_header(header) + } + + pub fn from_header(header: &HeaderValue) -> Result { + let header = header.to_str()?; + + let (scheme, token) = header.split_once(' ').unwrap_or(("", header)); + if !(scheme.is_empty() || scheme.eq_ignore_ascii_case("Bearer")) { + return Err(ApiTokenCredentialsError::InvalidAuthScheme); + } + + Self::from_raw_token(token.trim_ascii()) + } + + pub fn from_raw_token(token: &str) -> Result { + let hashed_token = HashedToken::parse(token)?; + Ok(Self { hashed_token }) + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result, BoxedAppError> { + let api_token = ApiToken::find_by_api_token(conn, &self.hashed_token) + .await + .map_err(|e| { + let cause = format!("invalid token caused by {e}"); + parts.request_log().add("cause", cause); + forbidden("authentication failed") + })?; + + let user = User::find(conn, api_token.user_id).await.map_err(|err| { + parts.request_log().add("cause", err); + internal("user_id from token not found in database") + })?; + + parts.request_log().add("uid", api_token.user_id); + parts.request_log().add("tokenid", api_token.id); + + AuthorizedUser::new(user, api_token) + .validate(conn, parts, permission) + .await + } +} + +impl FromRequestParts for ApiTokenCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + Ok(Self::from_request_parts(parts)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ApiTokenCredentialsError { + #[error("Missing `Authorization` header")] + MissingAuthorizationHeader, + #[error("Unexpected non-ASCII characters in `Authorization` header: {0}")] + InvalidCharacters(#[from] ToStrError), + #[error("Unexpected `Authorization` header scheme")] + InvalidAuthScheme, + #[error("Invalid API token: {0}")] + InvalidAccessToken(#[from] InvalidTokenError), +} + +impl From for BoxedAppError { + fn from(err: ApiTokenCredentialsError) -> Self { + if matches!(err, ApiTokenCredentialsError::MissingAuthorizationHeader) { + bad_request("token not provided") + } else if matches!(err, ApiTokenCredentialsError::InvalidAccessToken(_)) { + InsecurelyGeneratedTokenRevoked::boxed() + } else { + let message = format!("Authentication failed: {err}"); + custom(StatusCode::UNAUTHORIZED, message) + } + } +} diff --git a/src/auth/credentials/cookie.rs b/src/auth/credentials/cookie.rs new file mode 100644 index 00000000000..1e6fb2dd64b --- /dev/null +++ b/src/auth/credentials/cookie.rs @@ -0,0 +1,83 @@ +use crate::auth::{AuthorizedUser, Permission}; +use crate::controllers; +use crate::middleware::log_request::RequestLogExt; +use crate::util::errors::{BoxedAppError, custom, forbidden, internal}; +use axum::extract::FromRequestParts; +use crates_io_database::models::User; +use crates_io_session::SessionExtension; +use diesel_async::AsyncPgConnection; +use http::request::Parts; +use http::{StatusCode, header}; +use std::num::ParseIntError; + +#[derive(Debug, Clone, Copy)] +pub struct CookieCredentials { + user_id: i32, +} + +impl CookieCredentials { + pub fn new(user_id: i32) -> Self { + Self { user_id } + } + + pub fn from_request_parts(parts: &Parts) -> Result, CookieCredentialsError> { + let Some(session) = parts.extensions.get::() else { + error!("No `SessionExtension` found in request parts!"); + return Ok(None); + }; + + let Some(user_id) = session.get("user_id") else { + return Ok(None); + }; + + let user_id = user_id.parse()?; + + Ok(Some(Self { user_id })) + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result, BoxedAppError> { + let user = User::find(conn, self.user_id).await.map_err(|err| { + parts.request_log().add("cause", err); + internal("user_id from cookie not found in database") + })?; + + parts.request_log().add("uid", self.user_id); + + AuthorizedUser::new(user, ()) + .validate(conn, parts, permission) + .await + } +} + +impl FromRequestParts for CookieCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + + Self::from_request_parts(parts)?.ok_or_else(|| { + if parts.headers.get(header::AUTHORIZATION).is_some() { + forbidden("this action can only be performed on the crates.io website") + } else { + forbidden("this action requires authentication") + } + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CookieCredentialsError { + #[error("Authentication failed: Unexpected characters in `user_id` session value: {0}")] + InvalidCharacters(#[from] ParseIntError), +} + +impl From for BoxedAppError { + fn from(err: CookieCredentialsError) -> Self { + custom(StatusCode::UNAUTHORIZED, err.to_string()) + } +} diff --git a/src/auth/credentials/mod.rs b/src/auth/credentials/mod.rs new file mode 100644 index 00000000000..9cb8d8cc41f --- /dev/null +++ b/src/auth/credentials/mod.rs @@ -0,0 +1,11 @@ +mod api_token; +mod cookie; +mod publish; +mod trustpub; +mod user; + +pub use self::api_token::*; +pub use self::cookie::*; +pub use self::publish::*; +pub use self::trustpub::*; +pub use self::user::*; diff --git a/src/auth/credentials/publish.rs b/src/auth/credentials/publish.rs new file mode 100644 index 00000000000..a4f21edaf7d --- /dev/null +++ b/src/auth/credentials/publish.rs @@ -0,0 +1,115 @@ +use crate::auth::{ + ApiTokenCredentials, ApiTokenCredentialsError, AuthorizedEntity, CookieCredentials, + CookieCredentialsError, Permission, TrustPubCredentials, TrustPubCredentialsError, + UserCredentials, +}; +use crate::controllers; +use crate::util::errors::{BoxedAppError, forbidden}; +use axum::extract::FromRequestParts; +use crates_io_trustpub::access_token::AccessToken; +use diesel_async::AsyncPgConnection; +use http::header; +use http::request::Parts; + +pub enum PublishCredentials { + User(UserCredentials), + TrustPub(TrustPubCredentials), +} + +impl PublishCredentials { + pub fn from_request_parts(parts: &Parts) -> Result { + if let Some(credentials) = CookieCredentials::from_request_parts(parts)? { + return Ok(credentials.into()); + } + + let Some(header) = parts.headers.get(header::AUTHORIZATION) else { + return Err(PublishCredentialsError::AuthenticationRequired); + }; + + let header = header.to_str().map_err(ApiTokenCredentialsError::from)?; + + let (scheme, token) = header.split_once(' ').unwrap_or(("", header)); + if !(scheme.is_empty() || scheme.eq_ignore_ascii_case("Bearer")) { + return Err(PublishCredentialsError::InvalidApiTokenCredentials( + ApiTokenCredentialsError::InvalidAuthScheme, + )); + } + + let token = token.trim_ascii(); + if token.starts_with(AccessToken::PREFIX) { + Ok(TrustPubCredentials::from_raw_token(token)?.into()) + } else { + Ok(ApiTokenCredentials::from_raw_token(token)?.into()) + } + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result { + match self { + PublishCredentials::User(credentials) => { + let auth = credentials.validate(conn, parts, permission).await?; + Ok(AuthorizedEntity::User(Box::new(auth))) + } + PublishCredentials::TrustPub(credentials) => { + let auth = credentials.validate(conn, parts, permission).await?; + Ok(AuthorizedEntity::TrustPub(auth)) + } + } + } +} + +impl From for PublishCredentials { + fn from(credentials: CookieCredentials) -> Self { + PublishCredentials::User(credentials.into()) + } +} + +impl From for PublishCredentials { + fn from(credentials: ApiTokenCredentials) -> Self { + PublishCredentials::User(credentials.into()) + } +} + +impl From for PublishCredentials { + fn from(credentials: TrustPubCredentials) -> Self { + PublishCredentials::TrustPub(credentials) + } +} + +impl FromRequestParts for PublishCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + Ok(Self::from_request_parts(parts)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PublishCredentialsError { + #[error(transparent)] + InvalidCookieCredentials(#[from] CookieCredentialsError), + #[error(transparent)] + InvalidApiTokenCredentials(#[from] ApiTokenCredentialsError), + #[error(transparent)] + InvalidTrustPubCredentials(#[from] TrustPubCredentialsError), + #[error("Authentication required")] + AuthenticationRequired, +} + +impl From for BoxedAppError { + fn from(err: PublishCredentialsError) -> Self { + match err { + PublishCredentialsError::InvalidCookieCredentials(err) => err.into(), + PublishCredentialsError::InvalidApiTokenCredentials(err) => err.into(), + PublishCredentialsError::InvalidTrustPubCredentials(err) => err.into(), + PublishCredentialsError::AuthenticationRequired => { + forbidden("this action requires authentication") + } + } + } +} diff --git a/src/auth/credentials/trustpub.rs b/src/auth/credentials/trustpub.rs new file mode 100644 index 00000000000..b9107486da1 --- /dev/null +++ b/src/auth/credentials/trustpub.rs @@ -0,0 +1,104 @@ +use crate::auth::{AuthorizedTrustPub, Permission}; +use crate::controllers; +use crate::util::errors::{BoxedAppError, custom, forbidden}; +use axum::extract::FromRequestParts; +use crates_io_database::schema::trustpub_tokens; +use crates_io_trustpub::access_token::{AccessToken, AccessTokenError}; +use diesel::dsl::now; +use diesel::{ExpressionMethods, OptionalExtension, QueryDsl}; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use http::StatusCode; +use http::header::ToStrError; +use http::request::Parts; + +#[derive(Debug)] +pub struct TrustPubCredentials { + token: AccessToken, +} + +impl TrustPubCredentials { + pub fn from_request_parts(parts: &Parts) -> Result { + use TrustPubCredentialsError::*; + use http::header; + + let header = parts.headers.get(header::AUTHORIZATION); + let header = header.ok_or(MissingAuthorizationHeader)?; + let header = header.to_str()?; + + let (scheme, token) = header.split_once(' ').unwrap_or(("", header)); + if !(scheme.is_empty() || scheme.eq_ignore_ascii_case("Bearer")) { + return Err(InvalidAuthScheme); + } + + Self::from_raw_token(token.trim_ascii()) + } + + pub fn from_raw_token(token: &str) -> Result { + let token = token.parse::()?; + Ok(Self { token }) + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + _parts: &Parts, + permission: Permission<'_>, + ) -> Result { + let hashed_token = self.token.sha256(); + + let crate_ids = trustpub_tokens::table + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .filter(trustpub_tokens::expires_at.gt(now)) + .select(trustpub_tokens::crate_ids) + .get_result::>>(conn) + .await + .optional()? + .ok_or_else(|| forbidden("Invalid authentication token"))?; + + let crate_ids = crate_ids.into_iter().flatten().collect(); + + AuthorizedTrustPub::new(crate_ids) + .validate(permission) + .await + } + + pub fn unvalidated_token(&self) -> &AccessToken { + &self.token + } +} + +impl FromRequestParts for TrustPubCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + Ok(Self::from_request_parts(parts)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TrustPubCredentialsError { + #[error("Missing `Authorization` header")] + MissingAuthorizationHeader, + #[error("Unexpected non-ASCII characters in `Authorization` header: {0}")] + InvalidCharacters(#[from] ToStrError), + #[error("Unexpected `Authorization` header scheme")] + InvalidAuthScheme, + #[error("Invalid access token: {0}")] + InvalidAccessToken(#[from] AccessTokenError), +} + +impl From for BoxedAppError { + fn from(err: TrustPubCredentialsError) -> Self { + if matches!(err, TrustPubCredentialsError::InvalidAccessToken(_)) { + let message = "Invalid `Authorization` header: Failed to parse token"; + custom(StatusCode::UNAUTHORIZED, message) + } else if matches!(err, TrustPubCredentialsError::MissingAuthorizationHeader) { + let message = "Missing `Authorization` header"; + custom(StatusCode::UNAUTHORIZED, message) + } else { + let message = format!("Authentication failed: {err}"); + custom(StatusCode::UNAUTHORIZED, message) + } + } +} diff --git a/src/auth/credentials/user.rs b/src/auth/credentials/user.rs new file mode 100644 index 00000000000..bfa6295d7be --- /dev/null +++ b/src/auth/credentials/user.rs @@ -0,0 +1,92 @@ +use crate::auth::{ + ApiTokenCredentials, ApiTokenCredentialsError, AuthorizedUser, CookieCredentials, + CookieCredentialsError, Permission, +}; +use crate::controllers; +use crate::util::errors::{BoxedAppError, forbidden}; +use axum::extract::FromRequestParts; +use crates_io_database::models::ApiToken; +use diesel_async::AsyncPgConnection; +use http::header; +use http::request::Parts; + +#[derive(Debug)] +pub enum UserCredentials { + Cookie(CookieCredentials), + ApiToken(ApiTokenCredentials), +} + +impl UserCredentials { + pub fn from_request_parts(parts: &Parts) -> Result { + if let Some(credentials) = CookieCredentials::from_request_parts(parts)? { + return Ok(credentials.into()); + } + + let Some(header) = parts.headers.get(header::AUTHORIZATION) else { + return Err(UserCredentialsError::AuthenticationRequired); + }; + + let credentials = ApiTokenCredentials::from_header(header)?; + + Ok(credentials.into()) + } + + pub async fn validate( + &self, + conn: &mut AsyncPgConnection, + parts: &Parts, + permission: Permission<'_>, + ) -> Result>, BoxedAppError> { + match self { + UserCredentials::Cookie(credentials) => { + Ok(credentials.validate(conn, parts, permission).await?.into()) + } + UserCredentials::ApiToken(credentials) => { + Ok(credentials.validate(conn, parts, permission).await?.into()) + } + } + } +} + +impl From for UserCredentials { + fn from(credentials: CookieCredentials) -> Self { + UserCredentials::Cookie(credentials) + } +} + +impl From for UserCredentials { + fn from(credentials: ApiTokenCredentials) -> Self { + UserCredentials::ApiToken(credentials) + } +} + +impl FromRequestParts for UserCredentials { + type Rejection = BoxedAppError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + controllers::util::verify_origin(parts)?; + Ok(Self::from_request_parts(parts)?) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum UserCredentialsError { + #[error(transparent)] + InvalidCookieCredentials(#[from] CookieCredentialsError), + #[error(transparent)] + InvalidApiTokenCredentials(#[from] ApiTokenCredentialsError), + #[error("Authentication required")] + AuthenticationRequired, +} + +impl From for BoxedAppError { + fn from(err: UserCredentialsError) -> Self { + match err { + UserCredentialsError::InvalidCookieCredentials(err) => err.into(), + UserCredentialsError::InvalidApiTokenCredentials(err) => err.into(), + UserCredentialsError::AuthenticationRequired => { + forbidden("this action requires authentication") + } + } + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 00000000000..38db8836173 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,5 @@ +mod authorization; +mod credentials; + +pub use authorization::*; +pub use credentials::*; diff --git a/src/controllers/crate_owner_invitation.rs b/src/controllers/crate_owner_invitation.rs index 24547c0e650..e3e6786d2e6 100644 --- a/src/controllers/crate_owner_invitation.rs +++ b/src/controllers/crate_owner_invitation.rs @@ -1,6 +1,5 @@ use crate::app::AppState; -use crate::auth::AuthCheck; -use crate::auth::Authentication; +use crate::auth::{CookieCredentials, Permission, UserCredentials}; use crate::controllers::helpers::authorization::Rights; use crate::controllers::helpers::pagination::{Page, PaginationOptions, PaginationQueryParams}; use crate::models::crate_owner_invitation::AcceptError; @@ -43,16 +42,18 @@ pub struct LegacyListResponse { )] pub async fn list_crate_owner_invitations_for_user( app: AppState, + creds: CookieCredentials, req: Parts, ) -> AppResult> { let mut conn = app.db_read().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; - let user_id = auth.user_id(); + let permission = Permission::ListOwnCrateOwnerInvitations; + let auth = creds.validate(&mut conn, &req, permission).await?; + let user = auth.user(); let PrivateListResponse { invitations, users, .. - } = prepare_list(&app, &req, auth, ListFilter::InviteeId(user_id), &mut conn).await?; + } = prepare_list(&app, &req, user, ListFilter::InviteeId(user.id), &mut conn).await?; // The schema for the private endpoints is converted to the schema used by v1 endpoints. let crate_owner_invitations = invitations @@ -108,13 +109,17 @@ pub struct ListQueryParams { pub async fn list_crate_owner_invitations( app: AppState, params: ListQueryParams, + creds: CookieCredentials, req: Parts, ) -> AppResult> { let mut conn = app.db_read().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; + + let permission = Permission::ListCrateOwnerInvitations; + let auth = creds.validate(&mut conn, &req, permission).await?; + let user = auth.user(); let filter = params.try_into()?; - let list = prepare_list(&app, &req, auth, filter, &mut conn).await?; + let list = prepare_list(&app, &req, user, filter, &mut conn).await?; Ok(Json(list)) } @@ -142,7 +147,7 @@ impl TryFrom for ListFilter { async fn prepare_list( state: &AppState, req: &Parts, - auth: Authentication, + user: &User, filter: ListFilter, conn: &mut AsyncPgConnection, ) -> AppResult { @@ -151,8 +156,6 @@ async fn prepare_list( .enable_seek(true) .gather(req)?; - let user = auth.user(); - let config = &state.config; let mut crate_names = HashMap::new(); @@ -350,17 +353,19 @@ pub struct HandleResponse { pub async fn handle_crate_owner_invitation( state: AppState, parts: Parts, + creds: UserCredentials, Json(crate_invite): Json, ) -> AppResult> { let crate_invite = crate_invite.crate_owner_invite; let mut conn = state.db_write().await?; - let user_id = AuthCheck::default() - .check(&parts, &mut conn) - .await? - .user_id(); + + let permission = Permission::HandleCrateOwnerInvitation; + let auth = creds.validate(&mut conn, &parts, permission).await?; + let user = auth.user(); + let invitation = - CrateOwnerInvitation::find_by_id(user_id, crate_invite.crate_id, &mut conn).await?; + CrateOwnerInvitation::find_by_id(user.id, crate_invite.crate_id, &mut conn).await?; if crate_invite.accepted { invitation.accept(&mut conn).await?; diff --git a/src/controllers/krate/delete.rs b/src/controllers/krate/delete.rs index bc23449b15d..9b68f938155 100644 --- a/src/controllers/krate/delete.rs +++ b/src/controllers/krate/delete.rs @@ -1,6 +1,5 @@ use crate::app::AppState; -use crate::auth::AuthCheck; -use crate::controllers::helpers::authorization::Rights; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::krate::CratePath; use crate::email::Email; use crate::models::NewDeletedCrate; @@ -55,31 +54,24 @@ impl DeleteQueryParams { pub async fn delete_crate( path: CratePath, params: DeleteQueryParams, + creds: CookieCredentials, parts: Parts, app: AppState, ) -> AppResult { let mut conn = app.db_write().await?; - // Check that the user is authenticated - let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; - // Check that the crate exists let krate = path.load_crate(&mut conn).await?; + let owners = krate.owners(&mut conn).await?; - // Check that the user is an owner of the crate (team owners are not allowed to delete crates) + // Check that the user is authenticated and an owner of the crate + // (team owners are not allowed to delete crates) + let permission = Permission::DeleteCrate { + krate: &krate, + owners: &owners, + }; + let auth = creds.validate(&mut conn, &parts, permission).await?; let user = auth.user(); - let owners = krate.owners(&mut conn).await?; - match Rights::get(user, &*app.github, &owners).await? { - Rights::Full => {} - Rights::Publish => { - let msg = "team members don't have permission to delete crates"; - return Err(custom(StatusCode::FORBIDDEN, msg)); - } - Rights::None => { - let msg = "only owners have permission to delete crates"; - return Err(custom(StatusCode::FORBIDDEN, msg)); - } - } let created_at = krate.created_at; diff --git a/src/controllers/krate/follow.rs b/src/controllers/krate/follow.rs index 168bc8e807f..fe9df034c22 100644 --- a/src/controllers/krate/follow.rs +++ b/src/controllers/krate/follow.rs @@ -1,7 +1,7 @@ //! Endpoints for managing a per user list of followed crates use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{CookieCredentials, Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::controllers::krate::CratePath; use crate::models::{Crate, Follow}; @@ -39,9 +39,17 @@ async fn follow_target( tag = "crates", responses((status = 200, description = "Successful Response", body = inline(OkResponse))), )] -pub async fn follow_crate(app: AppState, path: CratePath, req: Parts) -> AppResult { +pub async fn follow_crate( + app: AppState, + path: CratePath, + creds: UserCredentials, + req: Parts, +) -> AppResult { let mut conn = app.db_write().await?; - let user_id = AuthCheck::default().check(&req, &mut conn).await?.user_id(); + + let permission = Permission::FollowCrate; + let user_id = creds.validate(&mut conn, &req, permission).await?.user_id(); + let follow = follow_target(&path.name, &mut conn, user_id).await?; diesel::insert_into(follows::table) .values(&follow) @@ -64,9 +72,17 @@ pub async fn follow_crate(app: AppState, path: CratePath, req: Parts) -> AppResu tag = "crates", responses((status = 200, description = "Successful Response", body = inline(OkResponse))), )] -pub async fn unfollow_crate(app: AppState, path: CratePath, req: Parts) -> AppResult { +pub async fn unfollow_crate( + app: AppState, + path: CratePath, + creds: UserCredentials, + req: Parts, +) -> AppResult { let mut conn = app.db_write().await?; - let user_id = AuthCheck::default().check(&req, &mut conn).await?.user_id(); + + let permission = Permission::UnfollowCrate; + let user_id = creds.validate(&mut conn, &req, permission).await?.user_id(); + let follow = follow_target(&path.name, &mut conn, user_id).await?; diesel::delete(&follow).execute(&mut conn).await?; @@ -91,15 +107,15 @@ pub struct FollowingResponse { pub async fn get_following_crate( app: AppState, path: CratePath, + creds: CookieCredentials, req: Parts, ) -> AppResult> { use diesel::dsl::exists; let mut conn = app.db_read_prefer_primary().await?; - let user_id = AuthCheck::only_cookie() - .check(&req, &mut conn) - .await? - .user_id(); + + let permission = Permission::ReadFollowState; + let user_id = creds.validate(&mut conn, &req, permission).await?.user_id(); let follow = follow_target(&path.name, &mut conn, user_id).await?; let following = diesel::select(exists(follows::table.find(follow.id()))) diff --git a/src/controllers/krate/owners.rs b/src/controllers/krate/owners.rs index 1d1a0691538..8ba9e403369 100644 --- a/src/controllers/krate/owners.rs +++ b/src/controllers/krate/owners.rs @@ -1,18 +1,18 @@ //! All routes related to managing owners of a crate -use crate::controllers::helpers::authorization::Rights; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::krate::CratePath; +use crate::email::Email; use crate::models::krate::OwnerRemoveError; use crate::models::{Crate, Owner, Team, User}; use crate::models::{ CrateOwner, NewCrateOwnerInvitation, NewCrateOwnerInvitationOutcome, NewTeam, - krate::NewOwnerInvite, token::EndpointScope, + krate::NewOwnerInvite, }; use crate::util::errors::{AppResult, BoxedAppError, bad_request, crate_not_found, custom}; use crate::views::EncodableOwner; use crate::{App, app::AppState}; -use crate::{auth::AuthCheck, email::Email}; -use axum::Json; +use axum::{Json, RequestPartsExt}; use chrono::Utc; use crates_io_github::{GitHubClient, GitHubError}; use diesel::prelude::*; @@ -161,7 +161,7 @@ pub struct ChangeOwnersRequest { async fn modify_owners( app: AppState, crate_name: String, - parts: Parts, + mut parts: Parts, body: ChangeOwnersRequest, add: bool, ) -> AppResult> { @@ -176,43 +176,27 @@ async fn modify_owners( } let mut conn = app.db_write().await?; - let auth = AuthCheck::default() - .with_endpoint_scope(EndpointScope::ChangeOwners) - .for_crate(&crate_name) - .check(&parts, &mut conn) - .await?; + let krate: Crate = Crate::by_name(&crate_name) + .first(&mut conn) + .await + .optional()? + .ok_or_else(|| crate_not_found(&crate_name))?; + + let owners = krate.owners(&mut conn).await?; + + let creds = parts.extract::().await?; + let permission = Permission::ModifyOwners { + krate: &krate, + owners: &owners, + }; + let auth = creds.validate(&mut conn, &parts, permission).await?; let user = auth.user(); let (msg, emails) = conn .transaction(|conn| { let app = app.clone(); async move { - let krate: Crate = Crate::by_name(&crate_name) - .first(conn) - .await - .optional()? - .ok_or_else(|| crate_not_found(&crate_name))?; - - let owners = krate.owners(conn).await?; - - match Rights::get(user, &*app.github, &owners).await? { - Rights::Full => {} - // Yes! - Rights::Publish => { - return Err(custom( - StatusCode::FORBIDDEN, - "team members don't have permission to modify owners", - )); - } - Rights::None => { - return Err(custom( - StatusCode::FORBIDDEN, - "only owners have permission to modify owners", - )); - } - } - // The set of emails to send out after invite processing is complete and // the database transaction has committed. let mut emails = Vec::with_capacity(logins.len()); diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index 1aa770be521..1ff63ebf258 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -1,7 +1,6 @@ //! Functionality related to publishing a new crate or version of a crate. use crate::app::AppState; -use crate::auth::{AuthCheck, AuthHeader, Authentication}; use crate::worker::jobs::{ self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion, }; @@ -11,7 +10,7 @@ use cargo_manifest::{Dependency, DepsSet, TargetDepsSet}; use chrono::{DateTime, SecondsFormat, Utc}; use crates_io_tarball::{TarballError, process_tarball}; use crates_io_worker::{BackgroundJob, EnqueueError}; -use diesel::dsl::{exists, now, select}; +use diesel::dsl::{exists, select}; use diesel::prelude::*; use diesel::sql_types::Timestamptz; use diesel_async::scoped_futures::ScopedFutureExt; @@ -21,7 +20,6 @@ use futures_util::TryStreamExt; use hex::ToHex; use http::StatusCode; use http::request::Parts; -use secrecy::ExposeSecret; use sha2::{Digest, Sha256}; use std::collections::HashMap; use tokio::io::{AsyncRead, AsyncReadExt}; @@ -33,45 +31,20 @@ use crate::models::{ VersionAction, default_versions::Version as DefaultVersion, }; -use crate::controllers::helpers::authorization::Rights; +use crate::auth::{Permission, PublishCredentials}; use crate::licenses::parse_license_expr; use crate::middleware::log_request::RequestLogExt; -use crate::models::token::EndpointScope; use crate::rate_limiter::LimitedAction; use crate::schema::*; -use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, forbidden, internal}; +use crate::util::errors::{AppResult, BoxedAppError, bad_request, custom, internal}; use crate::views::{ EncodableCrate, EncodableCrateDependency, GoodCrate, PublishMetadata, PublishWarnings, }; -use crates_io_database::models::{User, versions_published_by}; +use crates_io_database::models::versions_published_by; use crates_io_diesel_helpers::canon_crate_name; -use crates_io_trustpub::access_token::AccessToken; - -const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem to be an owner. \ - If you believe this is a mistake, perhaps you need \ - to accept an invitation to be an owner before \ - publishing."; const MAX_DESCRIPTION_LENGTH: usize = 1000; -enum AuthType { - Regular(Box), - TrustPub, -} - -impl AuthType { - fn user(&self) -> Option<&User> { - match self { - AuthType::Regular(auth) => Some(auth.user()), - AuthType::TrustPub => None, - } - } - - fn user_id(&self) -> Option { - self.user().map(|u| u.id) - } -} - /// Publish a new crate/version. /// /// Used by `cargo publish` to publish a new crate or to publish a new version of an @@ -87,7 +60,12 @@ impl AuthType { tag = "publish", responses((status = 200, description = "Successful Response", body = inline(GoodCrate))), )] -pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult> { +pub async fn publish( + app: AppState, + creds: PublishCredentials, + req: Parts, + body: Body, +) -> AppResult> { let stream = body.into_data_stream(); let stream = stream.map_err(std::io::Error::other); let mut reader = StreamReader::new(stream); @@ -147,60 +125,15 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult().map_err(|_| { - let message = "Invalid `Authorization` header: Failed to parse token"; - custom(StatusCode::UNAUTHORIZED, message) - })) - }) - .transpose()?; - - let auth = if let Some(trustpub_token) = trustpub_token { - let Some(existing_crate) = &existing_crate else { - let error = forbidden("Trusted Publishing tokens do not support creating new crates"); - return Err(error); - }; - - let hashed_token = trustpub_token.sha256(); - - let crate_ids: Vec> = trustpub_tokens::table - .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) - .filter(trustpub_tokens::expires_at.gt(now)) - .select(trustpub_tokens::crate_ids) - .get_result(&mut conn) - .await - .optional()? - .ok_or_else(|| forbidden("Invalid authentication token"))?; - - if !crate_ids.contains(&Some(existing_crate.id)) { - let name = &existing_crate.name; - let error = format!("The provided access token is not valid for crate `{name}`"); - return Err(forbidden(error)); - } - - AuthType::TrustPub - } else { - let endpoint_scope = match existing_crate { - Some(_) => EndpointScope::PublishUpdate, - None => EndpointScope::PublishNew, - }; - - let auth = AuthCheck::default() - .with_endpoint_scope(endpoint_scope) - .for_crate(&metadata.name) - .check(&req, &mut conn) - .await?; - - AuthType::Regular(Box::new(auth)) + let permission = match &existing_crate { + Some(krate) => Permission::PublishUpdate { krate }, + None => Permission::PublishNew { + name: &metadata.name, + }, }; + let auth = creds.validate(&mut conn, &req, permission).await?; + let verified_email_address = if let Some(user) = auth.user() { let verified_email_address = user.verified_email(&mut conn).await?; Some(verified_email_address.ok_or_else(|| verified_email_error(&app.config.domain_name))?) @@ -431,19 +364,10 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult krate, None => persist.update(conn).await?, - }; - - let owners = krate.owners(conn).await?; - if Rights::get(user, &*app.github, &owners).await? < Rights::Publish { - return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE)); } - - krate } else { // Trusted Publishing does not support creating new crates persist.update(conn).await? @@ -514,10 +438,10 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult> { // Notes: // The different use cases this function covers is handled through passing @@ -93,7 +93,7 @@ pub async fn list_crates( use diesel::sql_types::Float; use seek::*; - let filter_params = FilterParams::from(params, &req, &mut conn).await?; + let filter_params = FilterParams::from(params, &mut req, &mut conn).await?; let sort = filter_params.sort.as_deref(); let selection = ( @@ -356,7 +356,7 @@ struct FilterParams { impl FilterParams { async fn from( search_params: ListQueryParams, - parts: &Parts, + parts: &mut Parts, conn: &mut AsyncPgConnection, ) -> AppResult { const LETTER_ERROR: &str = "letter value must contain 1 character"; @@ -366,7 +366,11 @@ impl FilterParams { }; let auth_user_id = match search_params.following { - Some(_) => Some(AuthCheck::default().check(parts, conn).await?.user_id()), + Some(_) => { + let creds = parts.extract::().await?; + let permission = Permission::ListFollowedCrates; + Some(creds.validate(conn, parts, permission).await?.user_id()) + } None => None, }; diff --git a/src/controllers/session.rs b/src/controllers/session.rs index ce5300e6ae5..715953bb757 100644 --- a/src/controllers/session.rs +++ b/src/controllers/session.rs @@ -7,6 +7,7 @@ use http::request::Parts; use oauth2::{AuthorizationCode, CsrfToken, Scope, TokenResponse}; use crate::app::AppState; +use crate::auth::CookieCredentials; use crate::controllers::user::update::UserConfirmEmail; use crate::email::Emails; use crate::middleware::log_request::RequestLogExt; @@ -120,7 +121,8 @@ pub async fn authorize_session( // Log in by setting a cookie and the middleware authentication session.insert("user_id".to_string(), user.id.to_string()); - super::user::me::get_authenticated_user(app, req).await + let credentials = CookieCredentials::new(user.id); + super::user::me::get_authenticated_user(app, credentials, req).await } pub async fn save_user_to_database( diff --git a/src/controllers/token.rs b/src/controllers/token.rs index bfcd889d3b5..44229d112c9 100644 --- a/src/controllers/token.rs +++ b/src/controllers/token.rs @@ -3,7 +3,7 @@ use crate::schema::api_tokens; use crate::views::EncodableApiTokenWithToken; use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{ApiTokenCredentials, CookieCredentials, Permission, UserCredentials}; use crate::models::token::{CrateScope, EndpointScope}; use crate::util::errors::{AppResult, bad_request}; use crate::util::token::PlainToken; @@ -53,10 +53,13 @@ pub struct ListResponse { pub async fn list_api_tokens( app: AppState, Query(params): Query, + creds: CookieCredentials, req: Parts, ) -> AppResult { let mut conn = app.db_read_prefer_primary().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; + + let permission = Permission::ListApiTokens; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); let tokens: Vec = ApiToken::belonging_to(user) @@ -104,6 +107,7 @@ pub struct CreateResponse { )] pub async fn create_api_token( app: AppState, + creds: CookieCredentials, parts: Parts, Json(new): Json, ) -> AppResult> { @@ -112,14 +116,9 @@ pub async fn create_api_token( } let mut conn = app.db_write().await?; - let auth = AuthCheck::default().check(&parts, &mut conn).await?; - - if auth.api_token_id().is_some() { - return Err(bad_request( - "cannot use an API token to create a new API token", - )); - } + let permission = Permission::CreateApiToken; + let auth = creds.validate(&mut conn, &parts, permission).await?; let user = auth.user(); let max_token_per_user = 500; @@ -216,11 +215,15 @@ pub struct GetResponse { pub async fn find_api_token( app: AppState, Path(id): Path, + creds: UserCredentials, req: Parts, ) -> AppResult> { let mut conn = app.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; + + let permission = Permission::ReadApiToken; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); + let api_token = ApiToken::belonging_to(user) .find(id) .select(ApiToken::as_select()) @@ -247,11 +250,15 @@ pub async fn find_api_token( pub async fn revoke_api_token( app: AppState, Path(id): Path, + creds: UserCredentials, req: Parts, ) -> AppResult { let mut conn = app.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; + + let permission = Permission::RevokeApiToken; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); + diesel::update(ApiToken::belonging_to(user).find(id)) .set(api_tokens::revoked.eq(true)) .execute(&mut conn) @@ -271,14 +278,18 @@ pub async fn revoke_api_token( tag = "api_tokens", responses((status = 204, description = "Successful Response")), )] -pub async fn revoke_current_api_token(app: AppState, req: Parts) -> AppResult { +pub async fn revoke_current_api_token( + app: AppState, + creds: ApiTokenCredentials, + req: Parts, +) -> AppResult { let mut conn = app.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; - let api_token_id = auth - .api_token_id() - .ok_or_else(|| bad_request("token not provided"))?; - diesel::update(api_tokens::table.filter(api_tokens::id.eq(api_token_id))) + let permission = Permission::RevokeCurrentApiToken; + let auth = creds.validate(&mut conn, &req, permission).await?; + let api_token = auth.api_token(); + + diesel::update(api_tokens::table.filter(api_tokens::id.eq(api_token.id))) .set(api_tokens::revoked.eq(true)) .execute(&mut conn) .await?; diff --git a/src/controllers/trustpub/github_configs/create/mod.rs b/src/controllers/trustpub/github_configs/create/mod.rs index d7fffb6c1c3..b15e5456e81 100644 --- a/src/controllers/trustpub/github_configs/create/mod.rs +++ b/src/controllers/trustpub/github_configs/create/mod.rs @@ -1,9 +1,9 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::krate::load_crate; use crate::controllers::trustpub::github_configs::emails::ConfigCreatedEmail; use crate::controllers::trustpub::github_configs::json; -use crate::util::errors::{AppResult, bad_request, forbidden}; +use crate::util::errors::{AppResult, bad_request}; use axum::Json; use crates_io_database::models::OwnerKind; use crates_io_database::models::trustpub::NewGitHubConfig; @@ -32,6 +32,7 @@ mod tests; )] pub async fn create_trustpub_github_config( state: AppState, + creds: CookieCredentials, parts: Parts, json: json::CreateRequest, ) -> AppResult> { @@ -46,9 +47,6 @@ pub async fn create_trustpub_github_config( let mut conn = state.db_write().await?; - let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; - let auth_user = auth.user(); - let krate = load_crate(&mut conn, &json_config.krate).await?; let user_owners = crate_owners::table @@ -61,15 +59,11 @@ pub async fn create_trustpub_github_config( .load::<(i32, String, String, bool)>(&mut conn) .await?; - let (_, _, _, email_verified) = user_owners - .iter() - .find(|(id, _, _, _)| *id == auth_user.id) - .ok_or_else(|| bad_request("You are not an owner of this crate"))?; + let user_owner_ids = user_owners.iter().map(|(id, _, _, _)| *id).collect(); - if !email_verified { - let message = "You must verify your email address to create a Trusted Publishing config"; - return Err(forbidden(message)); - } + let permission = Permission::CreateTrustPubGitHubConfig { user_owner_ids }; + let auth = creds.validate(&mut conn, &parts, permission).await?; + let auth_user = auth.user(); // Lookup `repository_owner_id` via GitHub API diff --git a/src/controllers/trustpub/github_configs/delete/mod.rs b/src/controllers/trustpub/github_configs/delete/mod.rs index 51b0e533529..9ed53710780 100644 --- a/src/controllers/trustpub/github_configs/delete/mod.rs +++ b/src/controllers/trustpub/github_configs/delete/mod.rs @@ -1,7 +1,7 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::trustpub::github_configs::emails::ConfigDeletedEmail; -use crate::util::errors::{AppResult, bad_request, not_found}; +use crate::util::errors::{AppResult, not_found}; use axum::extract::Path; use crates_io_database::models::OwnerKind; use crates_io_database::models::trustpub::GitHubConfig; @@ -28,13 +28,11 @@ mod tests; pub async fn delete_trustpub_github_config( state: AppState, Path(id): Path, + creds: CookieCredentials, parts: Parts, ) -> AppResult { let mut conn = state.db_write().await?; - let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; - let auth_user = auth.user(); - // Check that a trusted publishing config with the given ID exists, // and fetch the corresponding crate ID and name. let (config, crate_name) = trustpub_configs_github::table @@ -57,10 +55,11 @@ pub async fn delete_trustpub_github_config( .load::<(i32, String, String, bool)>(&mut conn) .await?; - // Check if the authenticated user is an owner of the crate - if !user_owners.iter().any(|owner| owner.0 == auth_user.id) { - return Err(bad_request("You are not an owner of this crate")); - } + let user_owner_ids = user_owners.iter().map(|(id, _, _, _)| *id).collect(); + + let permission = Permission::DeleteTrustPubGitHubConfig { user_owner_ids }; + let auth = creds.validate(&mut conn, &parts, permission).await?; + let auth_user = auth.user(); // Delete the configuration from the database diesel::delete(trustpub_configs_github::table.filter(trustpub_configs_github::id.eq(id))) diff --git a/src/controllers/trustpub/github_configs/list/mod.rs b/src/controllers/trustpub/github_configs/list/mod.rs index 59c08934fcd..bd4cd5f3852 100644 --- a/src/controllers/trustpub/github_configs/list/mod.rs +++ b/src/controllers/trustpub/github_configs/list/mod.rs @@ -1,14 +1,12 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::krate::load_crate; use crate::controllers::trustpub::github_configs::json::{self, ListResponse}; -use crate::util::errors::{AppResult, bad_request}; +use crate::util::errors::AppResult; use axum::Json; use axum::extract::{FromRequestParts, Query}; -use crates_io_database::models::OwnerKind; use crates_io_database::models::trustpub::GitHubConfig; -use crates_io_database::schema::{crate_owners, trustpub_configs_github}; -use diesel::dsl::{exists, select}; +use crates_io_database::schema::trustpub_configs_github; use diesel::prelude::*; use diesel_async::RunQueryDsl; use http::request::Parts; @@ -37,29 +35,15 @@ pub struct ListQueryParams { pub async fn list_trustpub_github_configs( state: AppState, params: ListQueryParams, + creds: CookieCredentials, parts: Parts, ) -> AppResult> { let mut conn = state.db_read().await?; - let auth = AuthCheck::only_cookie().check(&parts, &mut conn).await?; - let auth_user = auth.user(); - let krate = load_crate(&mut conn, ¶ms.krate).await?; - // Check if the authenticated user is an owner of the crate - let is_owner = select(exists( - crate_owners::table - .filter(crate_owners::crate_id.eq(krate.id)) - .filter(crate_owners::deleted.eq(false)) - .filter(crate_owners::owner_kind.eq(OwnerKind::User)) - .filter(crate_owners::owner_id.eq(auth_user.id)), - )) - .get_result::(&mut conn) - .await?; - - if !is_owner { - return Err(bad_request("You are not an owner of this crate")); - } + let permission = Permission::ListTrustPubGitHubConfigs { krate: &krate }; + creds.validate(&mut conn, &parts, permission).await?; let configs = trustpub_configs_github::table .filter(trustpub_configs_github::crate_id.eq(krate.id)) diff --git a/src/controllers/trustpub/tokens/revoke/mod.rs b/src/controllers/trustpub/tokens/revoke/mod.rs index b0d79b2eb45..03723060566 100644 --- a/src/controllers/trustpub/tokens/revoke/mod.rs +++ b/src/controllers/trustpub/tokens/revoke/mod.rs @@ -1,12 +1,10 @@ use crate::app::AppState; -use crate::auth::AuthHeader; -use crate::util::errors::{AppResult, custom}; +use crate::auth::TrustPubCredentials; +use crate::util::errors::AppResult; use crates_io_database::schema::trustpub_tokens; -use crates_io_trustpub::access_token::AccessToken; use diesel::prelude::*; use diesel_async::RunQueryDsl; use http::StatusCode; -use secrecy::ExposeSecret; #[cfg(test)] mod tests; @@ -22,13 +20,11 @@ mod tests; tag = "trusted_publishing", responses((status = 204, description = "Successful Response")), )] -pub async fn revoke_trustpub_token(app: AppState, auth: AuthHeader) -> AppResult { - let token = auth.token().expose_secret(); - let Ok(token) = token.parse::() else { - let message = "Invalid `Authorization` header: Failed to parse token"; - return Err(custom(StatusCode::UNAUTHORIZED, message)); - }; - +pub async fn revoke_trustpub_token( + app: AppState, + creds: TrustPubCredentials, +) -> AppResult { + let token = creds.unvalidated_token(); let hashed_token = token.sha256(); let mut conn = app.db_write().await?; diff --git a/src/controllers/user/email_notifications.rs b/src/controllers/user/email_notifications.rs index baf6e760c6e..7b910440651 100644 --- a/src/controllers/user/email_notifications.rs +++ b/src/controllers/user/email_notifications.rs @@ -1,5 +1,5 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::models::{CrateOwner, OwnerKind}; use crate::schema::crate_owners; @@ -33,6 +33,7 @@ pub struct CrateEmailNotifications { #[deprecated] pub async fn update_email_notifications( app: AppState, + creds: UserCredentials, parts: Parts, Json(updates): Json>, ) -> AppResult { @@ -44,10 +45,10 @@ pub async fn update_email_notifications( .collect(); let mut conn = app.db_write().await?; - let user_id = AuthCheck::default() - .check(&parts, &mut conn) - .await? - .user_id(); + + let permission = Permission::UpdateEmailNotifications; + let auth = creds.validate(&mut conn, &parts, permission).await?; + let user_id = auth.user_id(); // Build inserts from existing crates belonging to the current user let to_insert = CrateOwner::by_owner_kind(OwnerKind::User) diff --git a/src/controllers/user/email_verification.rs b/src/controllers/user/email_verification.rs index 293d557ed5f..d7366424364 100644 --- a/src/controllers/user/email_verification.rs +++ b/src/controllers/user/email_verification.rs @@ -1,6 +1,6 @@ use super::update::UserConfirmEmail; use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::models::Email; use crate::util::errors::AppResult; @@ -58,10 +58,13 @@ pub async fn confirm_user_email( pub async fn resend_email_verification( state: AppState, Path(param_user_id): Path, + creds: UserCredentials, req: Parts, ) -> AppResult { let mut conn = state.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; + + let permission = Permission::ResendEmailVerification; + let auth = creds.validate(&mut conn, &req, permission).await?; // need to check if current user matches user to be updated if auth.user_id() != param_user_id { diff --git a/src/controllers/user/me.rs b/src/controllers/user/me.rs index 1b5989e77b1..ab22364eddc 100644 --- a/src/controllers/user/me.rs +++ b/src/controllers/user/me.rs @@ -1,4 +1,3 @@ -use crate::auth::AuthCheck; use axum::Json; use diesel::prelude::*; use diesel_async::RunQueryDsl; @@ -6,6 +5,7 @@ use futures_util::FutureExt; use http::request::Parts; use crate::app::AppState; +use crate::auth::{CookieCredentials, Permission}; use crate::controllers::helpers::Paginate; use crate::controllers::helpers::pagination::{Paginated, PaginationOptions}; use crate::models::krate::CrateName; @@ -22,12 +22,16 @@ use crate::views::{EncodableMe, EncodablePrivateUser, EncodableVersion, OwnedCra tag = "users", responses((status = 200, description = "Successful Response", body = inline(EncodableMe))), )] -pub async fn get_authenticated_user(app: AppState, req: Parts) -> AppResult> { +pub async fn get_authenticated_user( + app: AppState, + creds: CookieCredentials, + req: Parts, +) -> AppResult> { let mut conn = app.db_read_prefer_primary().await?; - let user_id = AuthCheck::only_cookie() - .check(&req, &mut conn) - .await? - .user_id(); + + let permission = Permission::ReadUser; + let auth = creds.validate(&mut conn, &req, permission).await?; + let user_id = auth.user_id(); let ((user, verified, email, verification_sent), owned_crates) = tokio::try_join!( users::table @@ -92,11 +96,13 @@ pub struct UpdatesResponseMeta { )] pub async fn get_authenticated_user_updates( app: AppState, + creds: CookieCredentials, req: Parts, ) -> AppResult> { let mut conn = app.db_read_prefer_primary().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; + let permission = Permission::ListUpdates; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); let followed_crates = Follow::belonging_to(user).select(follows::crate_id); diff --git a/src/controllers/user/update.rs b/src/controllers/user/update.rs index eb956c6b556..7a0399f220d 100644 --- a/src/controllers/user/update.rs +++ b/src/controllers/user/update.rs @@ -1,5 +1,5 @@ use crate::app::AppState; -use crate::auth::AuthCheck; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::models::NewEmail; use crate::schema::users; @@ -44,12 +44,14 @@ pub struct User { pub async fn update_user( state: AppState, Path(param_user_id): Path, + creds: UserCredentials, req: Parts, Json(user_update): Json, ) -> AppResult { let mut conn = state.db_write().await?; - let auth = AuthCheck::default().check(&req, &mut conn).await?; + let permission = Permission::UpdateUser; + let auth = creds.validate(&mut conn, &req, permission).await?; let user = auth.user(); // need to check if current user matches user to be updated diff --git a/src/controllers/version/docs.rs b/src/controllers/version/docs.rs index f6d907e6a05..dedf5acc3e0 100644 --- a/src/controllers/version/docs.rs +++ b/src/controllers/version/docs.rs @@ -2,9 +2,8 @@ use super::CrateVersionPath; use crate::app::AppState; -use crate::auth::AuthCheck; -use crate::controllers::helpers::authorization::Rights; -use crate::util::errors::{AppResult, custom, server_error}; +use crate::auth::{CookieCredentials, Permission}; +use crate::util::errors::{AppResult, server_error}; use crate::worker::jobs; use crates_io_worker::BackgroundJob as _; use http::StatusCode; @@ -24,23 +23,16 @@ use http::request::Parts; pub async fn rebuild_version_docs( app: AppState, path: CrateVersionPath, + creds: CookieCredentials, req: Parts, ) -> AppResult { let mut conn = app.db_write().await?; - let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; // validate if version & crate exist - let (_, krate) = path.load_version_and_crate(&mut conn).await?; - - // Check that the user is an owner of the crate, or a team member (= publish rights) - let user = auth.user(); - let owners = krate.owners(&mut conn).await?; - if Rights::get(user, &*app.github, &owners).await? < Rights::Publish { - return Err(custom( - StatusCode::FORBIDDEN, - "user doesn't have permission to trigger a docs rebuild", - )); - } + let (_, ref krate) = path.load_version_and_crate(&mut conn).await?; + + let permission = Permission::RebuildDocs { krate }; + creds.validate(&mut conn, &req, permission).await?; let job = jobs::DocsRsQueueRebuild::new(path.name, path.version); job.enqueue(&mut conn).await.map_err(|error| { diff --git a/src/controllers/version/update.rs b/src/controllers/version/update.rs index 272bb0517d2..e747c4e73ab 100644 --- a/src/controllers/version/update.rs +++ b/src/controllers/version/update.rs @@ -1,19 +1,17 @@ use super::CrateVersionPath; use crate::app::AppState; -use crate::auth::{AuthCheck, Authentication}; -use crate::controllers::helpers::authorization::Rights; -use crate::models::token::EndpointScope; +use crate::auth::{AuthorizedUser, Permission, UserCredentials}; use crate::models::{Crate, NewVersionOwnerAction, Version, VersionAction, VersionOwnerAction}; use crate::rate_limiter::LimitedAction; use crate::schema::versions; -use crate::util::errors::{AppResult, bad_request, custom}; +use crate::util::errors::{AppResult, bad_request}; use crate::views::EncodableVersion; use crate::worker::jobs::{SyncToGitIndex, SyncToSparseIndex, UpdateDefaultVersion}; use axum::Json; +use crates_io_database::models::ApiToken; use crates_io_worker::BackgroundJob; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; -use http::StatusCode; use http::request::Parts; use serde::Deserialize; @@ -49,13 +47,16 @@ pub struct UpdateResponse { pub async fn update_version( state: AppState, path: CrateVersionPath, + creds: UserCredentials, req: Parts, Json(update_request): Json, ) -> AppResult> { let mut conn = state.db_write().await?; let (mut version, krate) = path.load_version_and_crate(&mut conn).await?; validate_yank_update(&update_request.version, &version)?; - let auth = authenticate(&req, &mut conn, &krate.name).await?; + + let permission = Permission::UpdateVersion { krate: &krate }; + let auth = creds.validate(&mut conn, &req, permission).await?; state .rate_limiter @@ -63,7 +64,6 @@ pub async fn update_version( .await?; perform_version_yank_update( - &state, &mut conn, &mut version, &krate, @@ -97,48 +97,19 @@ fn validate_yank_update(update_data: &VersionUpdate, version: &Version) -> AppRe Ok(()) } -pub async fn authenticate( - req: &Parts, - conn: &mut AsyncPgConnection, - name: &str, -) -> AppResult { - AuthCheck::default() - .with_endpoint_scope(EndpointScope::Yank) - .for_crate(name) - .check(req, conn) - .await -} - pub async fn perform_version_yank_update( - state: &AppState, conn: &mut AsyncPgConnection, version: &mut Version, krate: &Crate, - auth: &Authentication, + auth: &AuthorizedUser>, yanked: Option, yank_message: Option, ) -> AppResult<()> { let api_token_id = auth.api_token_id(); let user = auth.user(); - let owners = krate.owners(conn).await?; let yanked = yanked.unwrap_or(version.yanked); - if Rights::get(user, &*state.github, &owners).await? < Rights::Publish { - if user.is_admin { - let action = if yanked { "yanking" } else { "unyanking" }; - warn!( - "Admin {} is {action} {}@{}", - user.gh_login, krate.name, version.num - ); - } else { - return Err(custom( - StatusCode::FORBIDDEN, - "must already be an owner to yank or unyank", - )); - } - } - // Check if the yanked state or yank message has changed and update if necessary let updated_cnt = diesel::update( versions::table.find(version.id).filter( diff --git a/src/controllers/version/yank.rs b/src/controllers/version/yank.rs index c18531606ee..ad0146b3010 100644 --- a/src/controllers/version/yank.rs +++ b/src/controllers/version/yank.rs @@ -1,11 +1,13 @@ //! Endpoints for yanking and unyanking specific versions of crates use super::CrateVersionPath; -use super::update::{authenticate, perform_version_yank_update}; +use super::update::perform_version_yank_update; use crate::app::AppState; +use crate::auth::{Permission, UserCredentials}; use crate::controllers::helpers::OkResponse; use crate::rate_limiter::LimitedAction; use crate::util::errors::AppResult; +use axum::RequestPartsExt; use http::request::Parts; /// Yank a crate version. @@ -62,7 +64,7 @@ pub async fn unyank_version( async fn modify_yank( path: CrateVersionPath, state: AppState, - req: Parts, + mut req: Parts, yanked: bool, ) -> AppResult { // FIXME: Should reject bad requests before authentication, but can't due to @@ -70,23 +72,20 @@ async fn modify_yank( let mut conn = state.db_write().await?; let (mut version, krate) = path.load_version_and_crate(&mut conn).await?; - let auth = authenticate(&req, &mut conn, &krate.name).await?; + + let creds = req.extract::().await?; + let permission = match yanked { + true => Permission::YankVersion { krate: &krate }, + false => Permission::UnyankVersion { krate: &krate }, + }; + let auth = creds.validate(&mut conn, &req, permission).await?; state .rate_limiter .check_rate_limit(auth.user_id(), LimitedAction::YankUnyank, &mut conn) .await?; - perform_version_yank_update( - &state, - &mut conn, - &mut version, - &krate, - &auth, - Some(yanked), - None, - ) - .await?; + perform_version_yank_update(&mut conn, &mut version, &krate, &auth, Some(yanked), None).await?; Ok(OkResponse::new()) } diff --git a/src/tests/krate/publish/trustpub.rs b/src/tests/krate/publish/trustpub.rs index 1f180f69dcc..0d9c06041d5 100644 --- a/src/tests/krate/publish/trustpub.rs +++ b/src/tests/krate/publish/trustpub.rs @@ -297,7 +297,7 @@ async fn test_non_existent_token_with_new_crate() -> anyhow::Result<()> { let pb = PublishBuilder::new("foo", "1.0.0"); let response = oidc_token_client.publish_crate(pb).await; assert_snapshot!(response.status(), @"403 Forbidden"); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Trusted Publishing tokens do not support creating new crates"}]}"#); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Invalid authentication token"}]}"#); Ok(()) } diff --git a/src/tests/routes/me/tokens/create.rs b/src/tests/routes/me/tokens/create.rs index 856c8f18e5d..b7a2039e750 100644 --- a/src/tests/routes/me/tokens/create.rs +++ b/src/tests/routes/me/tokens/create.rs @@ -116,8 +116,8 @@ async fn cannot_create_token_with_token() { br#"{ "api_token": { "name": "baz" } }"# as &[u8], ) .await; - assert_snapshot!(response.status(), @"400 Bad Request"); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"cannot use an API token to create a new API token"}]}"#); + assert_snapshot!(response.status(), @"403 Forbidden"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action can only be performed on the crates.io website"}]}"#); assert!(app.emails().await.is_empty()); } diff --git a/src/tests/routes/me/tokens/delete_current.rs b/src/tests/routes/me/tokens/delete_current.rs index 6db821fb85a..34fc7347ec2 100644 --- a/src/tests/routes/me/tokens/delete_current.rs +++ b/src/tests/routes/me/tokens/delete_current.rs @@ -43,8 +43,8 @@ async fn revoke_current_token_without_auth() { let (_, anon) = TestApp::init().empty().await; let response = anon.delete::<()>("/api/v1/tokens/current").await; - assert_snapshot!(response.status(), @"403 Forbidden"); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#); + assert_snapshot!(response.status(), @"400 Bad Request"); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"token not provided"}]}"#); } #[tokio::test(flavor = "multi_thread")]